Skip to content

Compared with alternatives ​

syncom is a data engine that keeps records on the device and replicates them between nodes. That puts it where four kinds of tools meet: the CRDT libraries (Yjs, Automerge, Loro, Jazz), which merge documents and leave storage and queries to you; the local-first databases (RxDB, PouchDB, TinyBase, Dexie Cloud, Fireproof), which store and query records and sync them through a backend; the peer-to-peer and event-sourced stores (GUN, OrbitDB, Evolu, LiveStore); and the sync services (ElectricSQL, PowerSync, Zero, Triplit, InstantDB), which replicate a server database to clients. The table compares syncom with eighteen of them, with every feature in one table rather than a few favourable rows. syncom is the first column. It is tinted, and on a wide screen it stays pinned next to the features while the table scrolls, so every row can be read against it.

How fast each one is, measured on the same scenarios, is on the Benchmarks page. RxDB, the closest of them, is compared with syncom feature by feature, in 300 rows, on Compared with RxDB.

What the table shows ​

  • A database or a merge, rarely both. The CRDT libraries merge text and lists character by character, but they have no queries, no indexes and no schema (Loro and Jazz have partial ones). The databases and services do query, but most of them resolve a conflict per document or per revision, or let the server decide. syncom sits between the two. It gives you records with a schema, Mango queries, indexes and unique keys, and it merges concurrent writes field by field, with counters that add up. That makes it a database that merges, not a document CRDT: it has no text or sequence type.
  • Few sync without a server of their own and still check what they receive. Nine of the nineteen need no server from the project to sync: syncom, Yjs, Automerge, Loro, RxDB, PouchDB, TinyBase, GUN and OrbitDB. Of those nine, only syncom and RxDB validate writes against a schema. Only RxDB and PouchDB let the node serving the data reject a write before it spreads (RxDB through its server plugin, PouchDB through CouchDB's validate_doc_update). A syncom host runs the same validators, hooks and scopes on a peer's op that it runs on its own writes, but it still relays an op its validator or hook skipped, so every node behind it judges that op for itself; only out-of-scope and unsigned ops stop at the host.
  • Counters that converge are rare. syncom, Automerge, Loro and Dexie Cloud have them; Dexie Cloud replays the operation on its server. Everywhere else, two replicas that increment the same number at the same time lose one of the increments.
  • A history that cannot be altered usually cannot be purged either. Five keep a tamper-evident history: syncom, Automerge, Jazz, Fireproof and OrbitDB. Automerge, Fireproof and OrbitDB cannot take a payload back out of it. syncom's purge() removes the payload from the op log and keeps the record of the operation; Jazz erases a CoValue on its server. Zero, Triplit, InstantDB and TinyBase can delete for good because they keep no history to begin with.
  • Unique keys across replicas. Only syncom and InstantDB enforce a unique key. syncom checks it on every write and reports a clash between two peers as a conflict event. In the others the key is per device, or it is checked only where the server writes to Postgres.
  • Where syncom is behind. It has no collaborative text and no list CRDT (Yjs, Automerge, Loro and Jazz do). End-to-end encryption is a recipe, field by field (Jazz signs and encrypts every change; Evolu encrypts). There are no UI bindings and no React Native target, and it is not yet on npm.
  • Some are no longer moving, or are moving away. PouchDB has not published since June 2024, and GUN's npm release is from July 2025. Triplit's last release was July 2025 and its site is gone. Instant Cloud closed to new signups and shuts down on 2027-08-31; InstantDB stays open source for self-hosting. Jazz's npm latest (0.20) is not the version its site documents (2.0, in alpha).

How the table was made ​

  • From the code, not the README. Each library was read at its latest npm version in October 2026: the published tarball, plus the official docs. Where the docs and the code disagree, the cell follows the code and a footnote says so. A service is judged as client and server together; the cell says which side a feature lives on.
  • What ✅ means. A feature counts as ✅ only when it ships in the project's own packages, and the package is named in the cell. A third-party package, a paid tier or an experimental flag makes it ⚠️, with the reason given.
  • The syncom column is this repository at the version on the page, checked against its tests.
  • The data is in the repository.docs/comparison.json keeps every cell with the file or page it was decided from, so you can check any of them. pnpm docs:comparison renders it into this page. If a cell is wrong, open an issue or edit the data; the table is generated from it.

The matrix ​

Featuresyncom
sync engine
Yjs
CRDT library
Automerge
CRDT library
Loro
CRDT library
Jazz
local-first framework
RxDB
local-first DB
PouchDB
local-first DB
TinyBase
reactive store
Dexie Cloud
IndexedDB + cloud
Fireproof
ledger DB
GUN
p2p graph DB
OrbitDB
p2p DB
Evolu
local-first SQLite
LiveStore
event-sourced SQLite
ElectricSQL
Postgres sync
PowerSync
Postgres/Mongo sync
Zero
query sync
Triplit
sync DB
InstantDB
sync backend
Package
Version compared0.0.1 (@alexify/syncom)13.6.33 (yjs)3.5.0 (@automerge/automerge); repo 2.6.0-alpha.311.16.4 (loro-crdt)0.20.19 (jazz-tools)217.6.0 (rxdb)9.0.0 (pouchdb)10.0.1 (tinybase)dexie 4.4.6 + dexie-cloud-addon 4.4.1630.24.19 (@fireproof/core, use-fireproof)0.2020.1241 (gun, SEA and RAD included)4.0.0 (@orbitdb/core)8.23.2 (@evolu/common)0.5.0 (@livestore/livestore, beta)@electric-sql/client 1.5.28; server 1.8.1@powersync/web 2.5.0 (node 1.2.0, react-native 2.3.3)1.11.0 (@rocicorp/zero)@triplit/client 1.0.50 (db 1.1.10, server 1.1.8)1.0.67 (@instantdb/core, /react, /admin)
Latest releasenot yet on npm (pre-release)2026-092026-092026-092026-072026-102024-0642026-092026-10 (addon), 2026-09 (dexie)2026-042025-0752026-052026-102026-102026-092026-102026-102025-07, no release since62026-087
LicenseMITMIT (providers and server MIT too)MIT (sync server MIT too)MIT (protocol packages MIT too)MIT (jazz-run server MIT); Jazz Cloud managedApache-2.0; Premium plugins paid8Apache-2.0 (CouchDB too)MITApache-2.0 client; server commercial9AFL-2.0 per npm; Apache-2.0 file10Zlib OR MIT OR Apache-2.0MITMIT (client and relay)Apache-2.0Apache-2.0, client and serverApache-2.0 client; service FSL-1.1-ALv211Apache-2.0 (client and zero-cache)AGPL-3.0: client, db and server12Apache-2.0 (client and server)
Runtime dependencies✅ 01 — lib0 (same author)✅ 0 (automerge-repo: 8)✅ 018 — zod, cojson, ProseMirror…24 direct + required peer rxjs; 74 installed14 (pouchdb); 3 (pouchdb-browser)✅ 0 (integrations are optional peers)5 (dexie-cloud-addon); dexie 05 (@fireproof/core)1 (ws) + optional @peculiar/webcrypto10 (+ Helia/libp2p you supply)5 (kysely, @noble/*, @scure/bip39, random)3 (+ peer effect)1: @microsoft/fetch-event-source5 (wa-sqlite, comlink, commander, …)48 (client and zero-cache ship together)134: @triplit/db, logger, comlink, superjson3: uuid, mutative, @instantdb/version
ImplementationJS (CommonJS), no build stepJS (ESM + CJS build), JSDoc typesRust → WASM, TS wrapperRust → WASMTS + Rust crypto core (WASM/N-API/RN)TypeScriptJavaScriptTypeScriptTS client; closed-source serverTypeScriptJS (hand-written UMD, no build)JS (ESM)TS; SQLite (WASM / native)TS on Effect; SQLite WASMElixir service + TS clientTS SDK + Rust SQLite extension; TS serviceTypeScript; zero-cache on Node + SQLiteTypeScript client + Node/Bun/Cloudflare serverTS/JS client + Clojure server on Postgres
Runtimes
Node.js, as a replica✅ ≥22, node:fs✅ y-leveldb + y-websocket client; Node ≥16✅ storage-nodefs + WebSocket adapter; Node ≥22.13 (repo)⚠️ runs (WASM); persistence is app code✅ workers, jazz-run sync (SQLite); Node ≥20⚠️ ≥20; free persistence via MongoDB/FoundationDB storages14✅ LevelDB via native leveldown✅ file, node:sqlite, Postgres persisters⚠️ IndexedDB shim only; not documented✅ files under ~/.fireproof✅ RAD files on disk; engines ≥0.8.4✅ ≥22; LevelDB + Helia blockstore❌ no Node client; relay only (≥24.20)✅ @livestore/adapter-node (Node, Bun, Deno)✅ client + PGlite on filesystem; no engines15✅ @powersync/node, better-sqlite3; no min declared⚠️ client in Node undocumented; memory fallback✅ client + sqlite/lmdb storage; engines ≥18❌ @instantdb/admin: online HTTP/SSE, no local store16
Browser persistence✅ OPFS, IndexedDB (/idb), memory✅ IndexedDB (y-indexeddb)✅ IndexedDB (storage-indexeddb)⚠️ none built in — app stores exported bytes✅ IndexedDB (cojson-storage-indexeddb)✅ IndexedDB (via Dexie), localStorage; OPFS Premium✅ IndexedDB; memory, localStorage adapters✅ IndexedDB, OPFS, localStorage, SQLite WASM✅ IndexedDB✅ IndexedDB (encrypted CAR files)✅ localStorage default; IndexedDB via lib/rindexed✅ IndexedDB via level + Helia blockstore✅ SQLite WASM on OPFS, encrypted✅ OPFS only; in-memory fallback✅ PGlite: IndexedDB, OPFS; client in-memory✅ SQLite: IndexedDB (default), OPFS, memory✅ IndexedDB (default) or memory✅ IndexedDB (cached in memory), memory17⚠️ IndexedDB cache: recent queries, pending txs
React Native / mobile❌ not a target; fsi is injectable⚠️ pure JS; RN storage third-party (y-op-sqlite)⚠️ Swift/Java bindings; React Native not documented⚠️ Swift (loro-swift); RN binding outside loro-dev org✅ React Native and Expo (SQLite)⚠️ React Native/Expo via Premium SQLite or filesystem⚠️ third-party pouchdb-adapter-react-native-sqlite✅ Expo SQLite, RN MMKV/SQLite, Capacitor⚠️ Capacitor/Electron WebView; no React Native❌ no React Native support documented⚠️ wiki recipe: gun/lib/mobile + AsyncStorage (lib/ras)❌ not documented✅ @evolu/react-native (Expo, SQLCipher)✅ @livestore/adapter-expo (expo-sqlite)✅ React Native/Expo: client react-native export✅ @powersync/react-native (op-sqlite), @powersync/capacitor✅ React Native/Expo: expo-sqlite, op-sqlite stores✅ @triplit/react-native (Expo), expo-sqlite storage✅ @instantdb/react-native (AsyncStorage, MMKV, expo-sqlite)
Server needed to sync✅ none — any node can host✅ none with y-webrtc; @y/websocket-server relay18✅ none required; automerge-repo-sync-server relay19✅ none required; loro-websocket SimpleServer⚠️ a sync server: Jazz Cloud or jazz-run sync✅ any backend: own endpoints, CouchDB, GraphQL, Supabase…✅ CouchDB-compatible, self-hostable; or none✅ none (BroadcastChannel) or WsServer relay⚠️ Dexie Cloud SaaS or paid on-prem⚠️ Fireproof Cloud; self-host undocumented20✅ none; any GUN peer relays (gun/lib/server)✅ none; libp2p relay for browsers⚠️ relay needed; self-host @evolu/relay / Docker21⚠️ central backend: Cloudflare DO, Electric, S2, custom⚠️ Electric service + Postgres ≥14; self-hostable⚠️ PowerSync Service + source DB + your API⚠️ zero-cache + Postgres 15+ + your API⚠️ @triplit/server required; self-host only⚠️ Instant server + Postgres; Docker self-host or Cloud
Data model
Data modelJSON records in collectionsshared types: Y.Map, Y.Array, Y.Text, XMLJSON-like CRDT documents, many per RepoJSON-like CRDT doc: Map, List, Text, Tree, CounterCoValues (maps, lists, feeds, text) in a graphJSON documents in collectionsJSON docs with revision treesin-memory tables + key-valuesIndexedDB tables; Y.Doc propertiesJSON docs in a Merkle CRDT ledgergraph of nodes (souls) with fieldsappend-only log; events, KV, documentsSQLite tables; LWW per columnevent log → SQLite tables (materializers)Postgres table rows, synced as shapesSQLite tables (views over JSON rows)Postgres tables (rows) with relationshipsdocuments in collections, schema-defined relationsentities in namespaces + links (triples in Postgres)
Schema validation of writes✅ metaschema, Standard Schema, ajv; local writes and peers' ops22❌ none❌ none — TS generics only⚠️ loro-mirror DSL; local setState only⚠️ Zod-based co.* schemas; runtime check defaults to warn23✅ JSON Schema via validate-ajv, validate-z-schema24❌ none client-side⚠️ cell types + defaults; zod etc. converted25❌ index spec only, no validation❌ none❌ none❌ none⚠️ Standard Schema types; app validates input26✅ Effect Schema per event, encoded on commit⚠️ Postgres constraints; Standard Schema via TanStack DB⚠️ Schema declares views; raw-table SQLite constraints27⚠️ mutator args via Standard Schema; schema.ts types only28✅ own DSL Schema as S; optional (schemaless mode)✅ own DSL i.schema/i.entity, enforced by server
Schema versions and migrations✅ version on every op; lens on read; migrate()❌ none❌ none❌ none⚠️ withMigration() on every load; no versions✅ version + migrationStrategies❌ none❌ no versioned schema⚠️ version().upgrade(), unsynced tables only❌ none; normalize in index functions❌ none❌ none⚠️ append-only schema; no migration functions✅ state rebuilt from the eventlog⚠️ your Postgres migrations; DDL forces shape re-sync29⚠️ none needed for JSON views; no migration API⚠️ Postgres DDL; expand/contract deploy order⚠️ schema push with compatibility checks; no migrations⚠️ instant-cli push schema diff + renames; no data migrations
Queries✅ Mango select(), order, top-k❌ none — app reads shared types❌ none — load documents by URL⚠️ JSONPath (RFC 9535) over one doc❌ none — traverse references from known ids✅ Mango (mingo); query-builder plugin✅ Mango (pouchdb-find), map/reduce✅ queries module (TinyQL: select, join, group)✅ where() on indexes + filter()✅ map-function indexes, key ranges⚠️ graph traversal; LEX key ranges via RAD⚠️ query(fn) scans the whole log30✅ typed SQL via Kysely✅ SQL + query builder (SQLite)✅ SQL where per shape; local SQL via PGlite✅ SQL (SQLite); Drizzle, Kysely drivers✅ ZQL builder; named queries resolved by your API✅ own builder: Where, Order, Include, subqueries✅ InstaQL (nested JSON: where, order, cursors)
Secondary indexes✅ entity index fields, equality only, in memory❌ none❌ none❌ none❌ none✅ schema indexes, compound✅ Mango indexes, map/reduce views✅ indexes module (slices)✅ compound, multi-entry✅ persistent prolly-tree indexes❌ none; app-built index nodes❌ none; keyvalue-indexed indexes keys only✅ indexes config (Kysely builder)✅ table indexes✅ PGlite CREATE INDEX; client has none✅ indexes in Table options✅ automatic: Postgres indexes mirrored; client in-memory❌ collection scan or id lookup only✅ .indexed() attributes, built on the server
Unique constraints✅ unique keys; a peer's clash is a conflict event❌ none❌ none❌ none⚠️ getOrCreateUnique: deterministic id per group❌ primary key only❌ _id only❌ row id only⚠️ & index per device; sync not documented❌ _id only❌ none❌ only the indexBy key❌ rejected by design⚠️ isUnique index, per client only31⚠️ Postgres UNIQUE, when your API writes⚠️ source DB on upload; raw tables locally⚠️ Postgres enforces; .unique() only declares❌ only the id key✅ .unique(), enforced by the server
Live queries / change events✅ watch(), change events✅ observe/observeDeep, doc update events✅ DocHandle change events with patches✅ subscribe, subscribeJsonpath✅ subscribe, useCoState hooks✅ $ observables (event-reduce)⚠️ changes({ live: true }) feed✅ listeners at every level; UI hooks✅ liveQuery()✅ subscribe(), useLiveQuery✅ .on() on any node or path✅ events.on('update'); no live queries✅ subscribeQuery, useQuery✅ queryDb, signals, computed✅ ShapeStream, Shape.subscribe, useShape✅ query().watch(), differentialWatch(), onChange✅ useQuery, materialize(); incremental view maintenance✅ client.subscribe, useQuery; incremental view maintenance✅ every query is live: useQuery, subscribeQuery
Collaborative text❌ none✅ Y.Text (rich), Y.XmlFragment; editor bindings✅ text with marks and blocks (Peritext)✅ LoroText rich text; ProseMirror/CodeMirror bindings✅ co.plainText(), co.richText(); ProseMirror❌ none❌ none❌ none✅ Y.Doc props via y-dexie (Yjs)❌ none❌ none❌ none❌ none❌ pair with Yjs / Automerge / Loro⚠️ Yjs via @electric-sql/y-electric provider❌ none built in; Yjs-as-rows demo❌ no text CRDT❌ no text CRDT; S.String is a register❌ none; strings are last-write values
Ordered lists that merge❌ no ordered list; an array field can be an observed-remove set (add()/remove())✅ Y.Array (YATA sequence)✅ lists with insertAt/deleteAt✅ LoroList, LoroMovableList, LoroTree✅ co.list() (ordered CRDT)⚠️ crdt plugin replays $push❌ arrays are part of the doc❌ array cells are LWW⚠️ Y.Array; add() sorts plain arrays32❌ arrays are part of the doc❌ set() is unordered (random keys)⚠️ events log: append-only, merged order❌ none⚠️ fractional indexing (Rocicorp package)⚠️ only inside a Yjs doc; columns replaced whole❌ rows, or a JSON column replaced whole❌ no sequence CRDT; rows or json() columns❌ S.Set only; arrays are replaced whole❌ arrays are JSON values; links are sets
Counters that converge✅ inc(): per-origin sums33❌ no counter type34✅ Counter type✅ LoroCounter❌ no counter type⚠️ crdt plugin $inc❌ one revision wins❌ cells are LWW✅ add(n)/remove(n) replayed on server❌ one winner per _id❌ LWW fields; concurrent increments overwrite❌ no counter store in v4❌ LWW columns only⚠️ increment events + your materializer❌ not built in; DIY in your API❌ no; PATCH uploads absolute values⚠️ server re-runs mutator; no counter type35❌ numbers are last-write registers❌ no increment op; read-modify-write
Replication
Topologymesh; hubs that dial each otherany — client–server or WebRTC meshany — client–server or peer-to-peerany — app moves bytesclient–server through sync serversclient–server; P2P over WebRTCmulti-master, any pair of DBsrelay hub, local peers, customclient–server onlyhub: cloud or custom gatewaymesh (DAM) with relay peersp2p mesh (libp2p pubsub)client–relay; several relays at onceclient–server (central backend)client–server only; CDN fan-outclient–server only (service + your backend)client–server: Postgres → zero-cache → clientsclient–server only, one sync serverclient–server only
Transport✅ injected link; wrpc WebSocket/WebRTC✅ providers: WebSocket, WebRTC; y-protocols sync✅ adapters: WebSocket, MessageChannel, BroadcastChannel✅ any (bytes); loro-websocket⚠️ WebSocket (cojson-transport-ws)✅ pluggable: HTTP, WebSocket, WebRTC, GraphQL…⚠️ HTTP (CouchDB protocol); socket-pouch third-party✅ WebSocket, BroadcastChannel, custom⚠️ HTTPS + WebSocket, fixed✅ pluggable gateways; cloud over WS/HTTP✅ WebSocket, WebRTC, UDP multicast; adapters✅ libp2p transports (TCP, WS, WebRTC…)⚠️ WebSocket only✅ pluggable SyncBackend; WS / HTTP in sync-cf⚠️ HTTP only: long-poll or SSE⚠️ HTTP streaming or WebSocket; not pluggable⚠️ fixed: WebSocket to zero-cache, HTTP to your API⚠️ WebSocket; transport option undocumented⚠️ WebSocket, SSE fallback; not pluggable
Browser to browser (WebRTC)✅ wrpc PeerLink + PeerHost✅ y-webrtc (signaling server only)36⚠️ no official adapter; automerge-repo-network-peerjs (third-party)⚠️ possible; no official adapter❌ not built in✅ replication-webrtc (simple-peer)❌ no maintained plugin❌ none built in❌ none❌ none built in✅ gun/lib/webrtc (relay for signaling)⚠️ via @libp2p/webrtc + circuit relay🔜 P2P planned (Protocol docs)❌ central backend required❌ every client reads from the Electric service❌ no; all data flows through the service❌ no peer-to-peer❌ sync always goes through the server❌ no; even presence/topics relay through the server
Partial replication✅ scopes per peer, enforced by the host on the record⚠️ per document (room); subdocuments37⚠️ per document; sharePolicy⚠️ per document (room)✅ per CoValue, loaded on demand⚠️ one replication per scope; backend filters✅ filter, doc_ids, selector❌ whole store per path✅ realms the user can access❌ whole ledger✅ on demand: only nodes you get❌ whole database log✅ per owner (useOwner, shard owners)⚠️ per store (storeId) only✅ shapes: table, where, columns, subset snapshots✅ Sync Streams (subscriptions with params, TTL)✅ per-query sync; TTL up to 10 min✅ per-query sync, filtered by read permissions✅ per query; view rules filter results
Resume after a disconnect✅ from the last event id✅ state-vector exchange on reconnect✅ sync state per peer, persisted✅ export({ mode: 'update', from }); rejoin by version✅ known-state diff per CoValue✅ checkpoints✅ checkpoints in _local docs✅ hash-tree diff on reconnect✅ delta since last server revision✅ clock heads; missing CAR files fetched⚠️ re-sends live gets with value hashes✅ heads exchange; fetch only missing entries✅ range-based set reconciliation✅ pull from cursor (seqNum)✅ offset + handle per shape✅ from last op id per bucket✅ base cookie + server CVR, kept 7 days✅ per-query checkpoint: server timestamp + cached ids⚠️ queries re-sent; server returns full results
Bootstrap a new replica✅ streamed snapshot past the log's retention✅ state-vector diff of merged state✅ whole compressed doc to an empty peer38✅ snapshot; shallow snapshot trims history❌ no snapshots — full history per CoValue✅ state-based: pulls current docs✅ changes feed: leaf revisions only✅ state-based: diff by content hashes✅ server snapshot of the user's realms⚠️ compact() merges CAR files, keeps clock39✅ state-based; no history to replay❌ newcomer traverses the full log❌ fetches the full change history🔜 full eventlog replay; compaction planned (#136)⚠️ shape log starts with a snapshot; compaction opt-in40⚠️ compacted bucket history, not a snapshot✅ clients hydrate current query results, no history✅ current query results, no history replay✅ server sends current query results
Conflict resolutionLWW per field by (stamp, origin); counters addCRDT (YATA); Y.Map key: higher clientID wins41CRDT; map key LWW by op id, losers kept42CRDT; Map LWW by Lamport, then peer idLWW per key by wall-clock madeAt; CoList CRDT43custom conflictHandler; default master winsdeterministic winner; _conflicts keptLWW per cell (HLC)server-authoritative; LWW per property44Merkle clock; deterministic winner per _idHAM: LWW per field, wall-clock state45LWW per key (Lamport clock, writer id)LWW per column (HLC)46backend total order + client rebase47server authority: your API writes Postgresserver authority: your backend decides48server authority; client rebases pending mutations49attribute patches; the last to reach the server wins50server order, last write per attribute; merge for JSON51
Offline writes✅ local op log, kept until the hosts have it52✅ local edits merge on reconnect (y-indexeddb)✅ local changes merge on reconnect✅ local edits merge on import✅ local-first; merges on reconnect✅ local writes, pushed later✅ local DB, replicate later✅ MergeableStore merges later✅ local IndexedDB, synced later✅ local ledger, synced later⚠️ local write; in-memory send queue53✅ local append; heads exchanged on join✅ local SQLite; synced later✅ local eventlog; pushed after rebase⚠️ DIY patterns; @tanstack/offline-transactions (TanStack)✅ local SQLite + upload queue (uploadData)❌ rejected once disconnected (default after 60 s)54✅ durable outbox, optimistic, rebased on server data✅ queued in IndexedDB, replayed on reconnect
A server can reject a write before it spreads⚠️ out-of-scope and unsigned ops stop at the host; one its validator or beforeApply skips is relayed55⚠️ none in @y/websocket-server; Hocuspocus hooks (third-party)⚠️ per-document access only; no change validation⚠️ authenticate → read/write per room; no op checks⚠️ signature/role checks; content opaque to server56✅ backend is master; RxServer changeValidator✅ CouchDB validate_doc_update❌ relay forwards client to client57⚠️ permissions enforced; no custom validation❌ cloud stores blocks and meta only⚠️ relay middleware can drop; SEA checks signatures⚠️ every peer checks canAppend + signature58❌ relay is blind (E2EE)✅ onPush can reject a batch (sync-cf)✅ your API is the only write path✅ writes reach others only via your backend✅ server mutators in a Postgres transaction✅ server re-checks schema and permissions; can reject✅ server applies every tx; rules can reject
Integrity and security
Tamper-evident history✅ hash-linked chain, unsigned (optional)59❌ none✅ SHA-256 change hash graph; checked on load❌ checksums detect corruption only✅ BLAKE3 hash chain per session❌ none❌ none❌ hashes serve sync diffs only❌ none✅ SHA-256 content-addressed Merkle clock⚠️ signed fields, # content-hash souls; no log✅ Merkle-DAG of CIDs (sha256, dag-cbor)⚠️ AEAD per change; no hash chain❌ none❌ not built in❌ bucket checksums catch corruption, not tampering❌ not built in❌ no hash chain❌ none
Signed changes✅ Ed25519 per op; the node id is the key; 'require' to enforce60❌ none❌ none — author is unsigned metadata❌ none✅ Ed25519 per session❌ none❌ none❌ none❌ none❌ none; cloud uses JWT auth✅ SEA signs user-space (~pubkey) writes61✅ every entry signed by its identity❌ shared owner key, no signatures❌ none❌ not built in❌ JWT per connection; changes unsigned❌ not built in; auth via cookie/token❌ JWT per connection, not per change❌ none; auth by session token
Encryption at rest⚠️ per record (encrypted: true), never synced62❌ none — y-indexeddb stores raw updates❌ none❌ none — storage is the app's⚠️ stored encrypted; keys kept locally63⚠️ encryption-crypto-js per field; WebCrypto Premium64⚠️ third-party crypto-pouch❌ none⚠️ dexie-encrypted (beta for Dexie 4)✅ AES-GCM by default⚠️ manual SEA.encrypt; stores plaintext otherwise⚠️ encryption module encrypts stored entries✅ SQLCipher (web, Electron, Expo)❌ not supported (issue #70)❌ not built in⚠️ opt-in encryptionKey; Node needs cipher fork⚠️ op-sqlite encryptionKey on React Native only❌ no local encryption❌ IndexedDB cache in plaintext
End-to-end encryption⚠️ recipe: wrpc's HPKE sealer in hooks, per field⚠️ y-webrtc: DTLS + room password❌ none (Keyhive pre-alpha, separate)⚠️ %ELO via EloAdaptor (experimental)65✅ XSalsa20 with group read keys❌ replication reads decrypted docs66⚠️ third-party comdb (beta)❌ none⚠️ dexie-encrypted above the sync layer67⚠️ blocks encrypted; key sent with meta68⚠️ manual SEA.encrypt / SEA.secret (ECDH)⚠️ @orbitdb/simple-encryption, unaudited69✅ XChaCha20-Poly1305, always on❌ DIY via schema transform (issue #70)❌ app-level; official example only❌ DIY: sync app-encrypted values❌ no; zero-cache runs queries on plaintext rows❌ server reads all data❌ no; server reads all data for rules
Access control where data is served✅ host scopes for reads and writes, authorize⚠️ per room, in your WebSocket upgrade handler⚠️ shareConfig.access per peer/doc (experimental)70⚠️ per-room read/write via server authenticate✅ Groups: reader/writer/admin, cryptographically enforced✅ RxServer authHandler, queryModifier, changeValidator (rxdb-server)71⚠️ CouchDB per-database roles72⚠️ authenticate the WS upgrade per path✅ realms, roles; table/row/property rules⚠️ per-ledger tokens via Fireproof Cloud⚠️ owner-only user space, SEA.certify; reads open⚠️ write ACLs; no read control73⚠️ owner keys; write-key token; relay isOwnerAllowed⚠️ DIY: validatePayload, onPush / onPull⚠️ your authorizing proxy; API open by default74✅ service: JWT + stream queries; backend: writes✅ your query/mutate endpoints, from server context✅ roles + per-collection filters, server-enforced✅ CEL-style rules in instant.perms.ts, server-side75
Purge: the payload leaves history✅ purge() scrubs the op log76⚠️ deleted content GC'd (gc: true default)77❌ history kept; delete() is local only⚠️ delete + shallow snapshot; redactJsonUpdates78✅ deleteCoValues (admin), erased in background⚠️ cleanup plugin purges tombstones79⚠️ purge(), indexeddb adapter only80✅ no history; a delete keeps no payload⚠️ DELETE /users/:id; records not documented❌ no API; Merkle history immutable⚠️ put(null) overwrites; opt-in lib/erase❌ immutable log; del appends a tombstone🔜 owner deletion planned; deleteOwner is a stub81❌ append-only eventlog⚠️ Postgres DELETE; shape logs keep the payload82⚠️ delete at source; compact drops old payloads✅ hard DELETE; no history kept✅ hard delete; no history to scrub✅ delete removes the triples; no history kept
History / time travel⚠️ op log within retention; repair(id); no read-at-version⚠️ Y.snapshot; needs gc: false✅ view(doc, heads), diff, getHistory✅ checkout, forkAt, revertTo✅ $jazz.getEdits(); branches (unstable)⚠️ latest revision; the crdt plugin keeps every op in the document⚠️ get(id, { rev }) until compaction⚠️ checkpoints: in-memory undo/redo❌ no API⚠️ changes(since); no read-at-version❌ latest value per field only⚠️ full oplog via log.iterator()✅ evolu_history table, queryable⚠️ eventlog via store.events()❌ no; replica=full adds old_value only❌ current checkpoint only❌ not built in❌ current state only❌ no versions; nightly backups only
Extensibility and operations
Write hooks / middleware✅ open({ hooks }): writes, reads, peer batches❌ none — doc events cannot veto❌ none❌ subscribePreCommit sets message/timestamp only❌ none✅ pre/post insert/save/remove⚠️ transform-pouch (pouchdb-community)✅ middleware: willSet*/willDel* (v8+)✅ DBCore middleware, table hooks❌ none (storage gatewayInterceptor only)✅ Gun.on('opt') → put/in/out middleware⚠️ custom access controller canAppend❌ none⚠️ materializers; onPush on the backend⚠️ your API; Phoenix.Sync.Writer callbacks (Elixir)⚠️ your backend's upload handler; no client hook✅ custom mutators + handleMutateRequest handler❌ no write hooks; permissions only⚠️ no write hooks; rules reject, webhooks after commit
Observability✅ OpenTelemetry spans and metrics (injected), stats()❌ none⚠️ setLoggerFactory, doc-metrics events; no OTel❌ none (Inspector for debugging)✅ OpenTelemetry metrics (jazz.*); no spans⚠️ logger plugin (Premium)⚠️ pouchdb-debug logging only⚠️ getStats() counters; dev Inspector❌ none; syncState observable⚠️ structured logs (FP_DEBUG), tracer events⚠️ console.STAT, lib/stats.js counters❌ none⚠️ structured Console; no OpenTelemetry✅ OpenTelemetry built in; devtools✅ server: OTLP traces, Prometheus/StatsD, Sentry⚠️ service: Prometheus metrics, JSON logs; client logger83✅ zero-cache: OTLP logs, traces, metrics84⚠️ log levels, client.logs; Sentry on server⚠️ client verbose/logger; server OTel traces to Honeycomb
Typed errors✅ SyncomError with a code❌ plain Errors, no codes❌ plain errors, no codes❌ plain Error messages, no codes⚠️ load states unavailable/unauthorized/deleted✅ RxError.code (e.g. COL23)✅ status + name (e.g. 409 conflict)⚠️ tinybase:<n> in messages, undocumented✅ Dexie.*Error by name; policy codes⚠️ NotFoundError; otherwise plain Error❌ ack.err strings❌ plain Error messages✅ typed errors (Result, EvoluError)✅ Effect tagged errors (_tag)⚠️ error classes, FetchError.status; no codes⚠️ service PSYNC_* codes; client errors untyped✅ ErrorKind codes, ApplicationError, StorageFailureError⚠️ TriplitError subclasses: name + HTTP status⚠️ InstantAPIError body.type on HTTP; transact untyped
TypeScript types⚠️ hand-written .d.ts; record data unknown✅ shipped .d.ts (from JSDoc)✅ TS .d.ts; Doc<T> generics✅ TS .d.ts; typed containers✅ inferred from co.* schemas✅ TS; doc type inferred from schema⚠️ @types/pouchdb (DefinitelyTyped)✅ TS; schema-typed with-schemas✅ TS; EntityTable, DexieCloudTable✅ TS; generic document types✅ shipped .d.ts❌ none shipped (JSDoc only)✅ TS, inferred from schema✅ TS, inferred from Effect Schema✅ TS; ShapeStream<Row> generic, no codegen✅ shipped; row types inferred from Schema✅ TS, inferred from schema.ts✅ TS, inferred from schema (Entity, QueryResult)✅ inferred from i.schema; instant-cli pull writes it
UI bindings❌ none⚠️ editor bindings official; React via react-yjs (third-party)✅ @automerge/react, Svelte store, Solid primitives✅ loro-mirror-react; editor bindings✅ React, RN/Expo, Svelte; Vue (community-jazz-vue)✅ rxdb/plugins/react; Vue, Angular, Preact signals⚠️ third-party use-pouchdb✅ ui-react, ui-solid, ui-svelte✅ dexie-react-hooks; liveQuery observables✅ use-fireproof React hooks❌ none official❌ none✅ @evolu/react, @evolu/vue, @evolu/svelte✅ @livestore/react, solid, svelte✅ React useShape (@electric-sql/react)✅ @powersync/react, @powersync/vue, TanStack✅ @rocicorp/zero/react, /solid; Vue/Svelte community✅ @triplit/react, react-native, svelte, vue, angular, solid✅ React, React Native, Svelte, Vue, SolidJS packages

✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. Every cell of the alternatives was decided by reading the source of the latest published package and its official docs in October 2026, not its README; the syncom column is this repository.

  1. npm latest of @automerge/automerge-repo and its adapters points at an alpha (2.6.0-alpha.3); the last stable is 2.5.6 (2026-05).
  2. Jazz 2.0 is a rewrite in alpha (2.0.0-alpha.59: tables, SQL-like queries) and jazz.tools/docs already documents it; the 0.20 docs ship in the package (bin/docs-index.txt) and at classic.jazz.tools.
  3. The addon peers on dexie ^4.4.7, which is published only under the dev tag as of 2026-10-21.
  4. No npm release since June 2024.
  5. npm lags the repository: master has commits through October 2026, but nothing has been published since July 2025.
  6. Effectively unmaintained: the last commit on main is from 2025-09-11, the co-founder joined Supabase on 2025-10-08 and Supabase said it would not integrate Triplit, and www.triplit.dev (docs and cloud) returns 410 Gone as of 2026-10. The repo is not archived, but no maintainer has answered a 2026 issue asking whether the project is maintained.
  7. Instant Cloud is closed to new signups and shuts down on 2027-08-31 after the team joined OpenAI; the code stays open source for self-hosting.
  8. The open-source core limits open collections across the process: the docs say 13; in the code (src/rx-collection.ts:258-262) the 14th opens after a delay of about 1.8 s and the 15th throws COL23. RxServer (rxdb-server) is SSPL.
  9. Free SaaS tier allows 3 production users; on-prem is a one-time licence from €3,495, source code only with Enterprise.
  10. package.json says AFL-2.0, the shipped LICENSE.md is Apache-2.0, and the repo README says MIT or Apache-2.0.
  11. The PowerSync Service (needed to sync) is source-available under the Functional Source License: competing commercial use is barred, and each version becomes Apache-2.0 two years after release; the SQLite core extension is Apache-2.0.
  12. The browser client itself is AGPL-3.0-only, not just the server. @triplit/db, @triplit/server and @triplit/server-core declare no license on npm, so the AGPL-3.0 LICENSE at the repo root applies to them.
  13. Most are zero-cache's (fastify, postgres, @opentelemetry/sdk-node, @rocicorp/zero-sqlite3, ws, jose…); the client modules import only a few (e.g. @rocicorp/lock, @rocicorp/logger, @badrap/valita).
  14. Filesystem and SQLite storages are Premium; the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance (the 251st serial insert fails).
  15. @electric-sql/client alone keeps rows in memory; a persisted Node replica is PGlite (@electric-sql/pglite) fed by @electric-sql/pglite-sync.
  16. @instantdb/core itself does nothing without window (no socket, no storage), so Node gets only the online admin SDK.
  17. The default is storage: 'memory': data only persists when you pass indexeddb. There is no OPFS or browser SQLite option.
  18. The official WebSocket server has no authentication or update validation; its sample upgrade handler only says 'You may check auth of request here'.
  19. The official sync server calls itself 'an unsecured Express app' — authentication is yours to add.
  20. toCloud() defaults to dev endpoints (fireproof-v2-cloud-dev.jchris.workers.dev, dev.connect.fireproof.direct); the backend source sits in the repo's cloud/.
  21. The default transport is wss://free.evoluhq.com, a testing relay that admits anyone and may delete data.
  22. A snapshot merge (bootstrap) and an op from a newer schema version (ahead: 'apply', the default) are applied unvalidated, and a record outside any collection has no validator.
  23. Runtime validation (0.20.10+) defaults to warn: invalid local writes go through with a console warning, although the source comment says strict.
  24. Documents are validated only when the storage is wrapped with a validator plugin.
  25. A cell that fails the schema is dropped or defaulted silently, and schematizers keep only type, enum and default.
  26. Mutations accept already-validated values: Evolu does not run the column validators on insert/update, nor on received changes.
  27. The client Schema does not validate writes (mismatched types are silently CAST); validation is your backend's on upload, and a rejected write is rolled back at the next checkpoint.
  28. createSchema/table().columns() only type ZQL and declare relationships: the client's insert/update store values unchecked, and Postgres checks them when the server mutator commits.
  29. Adding, removing or renaming a column invalidates every shape on that table, so clients get 409/must-refetch, drop their local copy and re-sync from scratch.
  30. Documents.get(key) also walks the oplog from the head, so reads cost O(log length).
  31. Two clients can commit events that collide; a materializer that then fails rolls back that event, which the docs say will 'typically halt' the system.
  32. add([x])/remove([x]) merge across sync but return a sorted array, so element order is not kept.
  33. A counter that one node resets while another increments it does not converge to the intuitive value; see Guarantees and limits in the Sync guide.
  34. A number in a Y.Map is a register: of two concurrent increments, one is lost.
  35. Increments add up only if the server mutator applies them atomically (raw SQL n = n + 1, or serializable + tx.retryOn); the default isolation is Postgres read committed.
  36. y-webrtc defaults to the public signaling server wss://y-webrtc-eu.fly.dev; pass your own signaling list (and a password) for anything private.
  37. The docs say providers sync subdocuments, but y-websocket, y-webrtc and y-indexeddb contain no subdocument handling — you open one provider per subdocument.
  38. The compressed document still carries the full change history — Automerge does not drop history.
  39. Compaction copies every block the clock reaches into the new CAR, so a newcomer still downloads the history.
  40. Docs still call compaction planned, but the code has it behind an undocumented experimental_compaction shape param (default false), so by default a newcomer replays the snapshot plus every change since the shape was created.
  41. Not wall-clock LWW: a concurrent Y.Map set is won by the writer with the higher random clientID, whatever the times.
  42. The winner is chosen by operation id (counter, then actor), not wall-clock time; getConflicts returns the losing values.
  43. compareTransactions returns 0 for equal madeAt from different sessions, so a same-millisecond tie falls to arrival order.
  44. A whole-object put() overwrites concurrent property updates; where().modify() is re-run on the server.
  45. Order comes from each writer's machine clock (+new Date): a write stamped in the future is held in a setTimeout until then and wins; equal states break by comparing JSON strings.
  46. A change stamped more than 5 minutes ahead of the receiver's clock is quarantined until system time catches up.
  47. Merge-conflict handling isn't implemented yet (#253): concurrent events simply apply in the backend's order.
  48. The client never merges: it holds its checkpoint while uploads are pending, then adopts the server's checkpoint exactly; plain UPDATEs in the backend give per-field LWW by arrival order.
  49. No field-level merge: the server re-runs each mutator against current Postgres state, so concurrent writes to a column are last-processed-wins unless your mutator checks.
  50. The FAQ still describes per-attribute HLC timestamps from 0.x. In 1.x the server stamps each incoming batch with its own clock when it arrives, so an edit made offline hours ago overwrites newer values when it finally syncs.
  51. Order is server arrival, not a timestamp: an offline write replayed on reconnect overwrites newer writes made meanwhile.
  52. Ops written before any node was registered, and compacted since, cannot reach a host (UNDELIVERABLE).
  53. Reconnect re-sends only queued messages held in memory and live gets; there is no persisted outbox, and src/websocket.js:5 still reads 'TODO: resync upon reconnect'.
  54. Writes queue only while connecting (disconnectTimeoutMs); docs-vs-code: the shipped client also accepts writes in needs-auth, which the docs list as rejected.
  55. A withheld op would leave a gap in its origin's stream, so a host that skips an op still relays it and every node re-checks it; a throwing beforeApply refuses the whole page instead.
  56. A write by a non-writer is stored and relayed, then ignored by every reader (markInvalid); content checks need a server worker.
  57. middleware on the server's own MergeableStore guards only that copy; WsServer still relays the message to the other clients.
  58. A rejected incoming entry is caught and only console.error'd; the app gets no event for it.
  59. Whoever can write the directory can rebuild the chain; a read checks a record against the block its file names, so a block planted beside the chain passes. The ops in the log are what is signed.
  60. The default 'verify' takes an unsigned op; sync.signatures: 'require' refuses it. A bootstrap re-signs the host's state under the joiner's key, and hello does not yet prove possession of the key.
  61. Only data under a user's ~pubkey soul is signed and verified; the public graph accepts unsigned writes from anyone.
  62. The RSA key pair that unseals the records sits unprotected in <path>/keys/, beside the data.
  63. The browser keeps the account secret in localStorage by default, so this protects data from the server, not from someone with the device.
  64. The free plugin is AES-CBC in crypto-js's passphrase mode (MD5 key derivation, no MAC); an unsalted SHA-256 of the password is stored beside the data.
  65. The Loro team says not to rely on it for production-grade security yet.
  66. The encryption wrapper sits below collection.storageInstance, which is what the replication reads.
  67. Needs the encryption middleware moved above sync by hand; indexed fields are not encrypted.
  68. MetaGateway.put attaches the ledger's key material to every meta update, so the sync service holds the key although the docs call sync end-to-end.
  69. In 4.0.0 the documents type destructures encrypt instead of encryption, so document stores silently stay unencrypted (issue #1253, fix PR #1257 open).
  70. Peer ids are self-asserted in the WebSocket join message, so access by peer id needs authentication at the socket.
  71. RxServer is SSPL and its defaults are open: with no authHandler every request is admitted, and the Express adapter's CORS reflects any origin with credentials.
  72. CouchDB has no per-document read rules; the usual pattern is a database per user.
  73. Anyone who knows a database address can replicate and read it unless it is encrypted.
  74. Electric serves any data its Postgres role can read to any caller; ELECTRIC_SECRET must be added by a proxy that also pins the shape's table and where.
  75. A rule that is not set evaluates to true: everything is open until you write rules.
  76. The chain keeps an unsalted SHA-256 of every version of the record, which can confirm a guessed record.
  77. GC clears the document state only; update logs kept by providers or peers hold the payload until they compact (y-indexeddb merges after 500 updates).
  78. A shallow snapshot only syncs with peers past its start version, and other replicas keep their full history.
  79. A removed document keeps its data in the tombstone until cleanup (default 31 days, after replication is in sync).
  80. Purges do not replicate; other adapters return an error.
  81. deleteOwner and deleteDatabase call todo(); rows can only be soft-deleted (isDeleted) and stay in evolu_history.
  82. Without compaction a deleted row's earlier entries stay in the shape log on the Electric server (and in HTTP caches) and are replayed to new clients until the shape is deleted (DELETE /v1/shape) or rotated.
  83. No tracing and no OTLP push to your own collector, yet the self-hosted service pushes anonymous metrics over OTLP to pulse.journeyapps.com unless telemetry.disable_telemetry_sharing: true.
  84. zero-cache also sends anonymous usage telemetry to Rocicorp by default (ZERO_ENABLE_TELEMETRY=false or DO_NOT_TRACK=1 to opt out).

Where each one fits ​

  • syncom: records with a schema, queries and live views, kept on the device and replicated between servers, browsers and peers. Any node can host, so no service is needed, and the history is verifiable and can be purged. Zero dependencies.
  • Yjs, Automerge, Loro: collaborative editing. Text, rich text, lists and trees merge character by character; storage, queries and access control are yours. Yjs has the largest ecosystem of editor bindings, Automerge keeps the full history, and Loro has the richest container types.
  • Jazz: an app framework built on signed, encrypted CoValues, with groups and roles. It needs a sync server (Jazz Cloud or self-hosted).
  • RxDB: a reactive JSON-document database with many storages and replication plugins. Disk storage on Node.js and several features are Premium.
  • PouchDB: CouchDB's replication protocol in the browser, with a CouchDB as the hub.
  • TinyBase: a small reactive in-memory store with many persisters and a simple sync protocol.
  • Dexie Cloud: IndexedDB with a hosted (or licensed on-prem) sync service, realms and roles.
  • Fireproof: an encrypted, content-addressed document ledger that syncs through its cloud.
  • GUN, OrbitDB: peer-to-peer graphs and logs, over GUN's own mesh or libp2p and IPFS.
  • Evolu: end-to-end encrypted SQLite on the device, synced through a blind relay.
  • LiveStore: event sourcing into SQLite on the client, with a central sync backend.
  • ElectricSQL, PowerSync, Zero: Postgres (or MongoDB, for PowerSync) is the source of truth. Clients get shapes, buckets or query results, and writes go through your API.
  • Triplit, InstantDB: a sync database with its own server, query language and permissions.

Released under the MIT License.