Compared with RxDB
RxDB is the alternative closest to syncom. Both keep JSON records with a schema on the device, query them with Mango selectors, watch them live, and replicate them without a server of the project's own. They differ in what crosses the wire and in who decides the outcome of a conflict. RxDB replicates the latest state of each document through checkpoints, and a conflict is settled per document: the master's state wins by default, or your handler or its CRDT plugin decides. syncom replicates every write as a signed operation and merges concurrent writes field by field on every node, with counters that add up and sets that keep every concurrent add.
The two also stand in different places. RxDB has been on npm since 2016. It is a broad plugin ecosystem with storages for every JavaScript runtime and replication plugins for a dozen backends, and part of its storage layer is paid (RxDB Premium). syncom is a zero-dependency pre-release (0.0.1, not on npm yet), MIT from end to end, and much narrower.
This page compares the two feature by feature, every row decided from source code: 300 rows in eight sections. Compared with alternatives puts both next to seventeen other libraries, and Benchmarks measures them on the same scenarios.
What the table shows
- RxDB is the broader toolkit. It has storages for every JavaScript runtime, replication plugins for nine backends and handlers that sync with one you already run, a richer write API (bulk writes,
modify(), Mongo update operators, update by query), sorted compound indexes, live queries that update without re-running (EventReduce), attachments, local documents, storage migration, backups, several tabs on one database, document types inferred from the schema, and React, Vue, Angular and signal bindings. In 51 rows RxDB has something where syncom has nothing; the rows are not equal in weight, so read the sections that matter to you. - syncom is the stricter replicator. Every write is an operation that merges field by field on every node, with no plugin to turn on: concurrent edits to different fields both stay, counters add up, sets keep every concurrent add. Every op is signed by the key that is the node's id, a hash-linked chain re-checks what is read,
repair(id)rebuilds a record from the log, andpurge()erases a payload on every replica it reaches. Any node hosts, a browser included; the host limits, validates and reports. In 20 rows syncom has something where RxDB has nothing: signatures, a tamper-evident log, unique keys, multi-entry and combined indexes, projection, text search inside a query, OpenTelemetry, stats among them. - Several RxDB guarantees depend on a switch. Without a
validate-*storage wrapper, production RxDB stores documents with wrong types, unknown fields and missing required fields.finalfields, frozen documents and the checks on queries and ids run only in dev-mode, and dev-mode in turn loads a hidden iframe from rxdb.info on any host that has no Premium flag. syncom has no such mode: validation is on for every entity with a schema, and the checks and full error messages are always on. - What is free. In RxDB, disk persistence on Node.js (filesystem, SQLite), OPFS, native IndexedDB, WebCrypto encryption, full-text search, the worker storages and the logger are Premium, a yearly term license. The free core caps open collections at 13 per process (a 14th waits, a 15th throws), and its SQLite trial fails at the 251st serial insert. Everything in syncom is MIT, Node.js disk persistence and full-text search included.
- Conflicts are settled differently. RxDB's default handler keeps the server's document, whatever its time (several RxDB pages call it last-write-wins); a handler of your own, or the
crdtplugin, merges instead. That plugin keeps every operation inside the document forever, orders concurrent ones by a random storage token, andupsert()writes around it. syncom's rules are fixed and cannot be replaced, a nested object is a single register, and a concurrent overwrite that loses is not reported. - Encryption ends at the device in both. RxDB's free plugin is AES-CBC through crypto-js with an MD5 key derivation and no MAC, stores an unsalted hash of the password, and replicates encrypted fields in plain text. syncom seals a record with AES-GCM under an RSA key, but that key sits in the same directory, and a sealed record never syncs. Neither has end-to-end encryption built in or rotates a key.
- Where syncom's checks stop. A host that skips a peer's op (a failed validator or
beforeApply) still relays it — a withheld op would be a gap in its origin's stream — so the peers behind it decide for themselves; what stops at the host is an op outside the peer's scopes (by the scope it declares or by its record's), a malformed or skewed one and a refused signature. A nested object merges as one register. Writes made offline before the first node was registered, and compacted since, cannot reach a host (it is reported). Making this page turned up a dozen syncom bugs, among them scopes a peer could write around, offline writes past retention that never arrived and a restored directory that forked its node in silence; they are fixed, with tests, and the cells describe the code as it is now. - Neither has collaborative text, multi-record transactions, aggregation or joins.
- Maturity. RxDB has been on npm since 2016, ships a minor about every month and a major about once a year (four of the last five asked users of some storages to migrate their stored data), and offers paid support. syncom is a pre-release with no users yet.
How the table was made
- RxDB 17.6.0, from its code. The RxDB column is
rxdb@17.6.0, the npmlatest(released 2026-10-05), read at its git tag. Every page of rxdb.info was read too, all 271 in its sitemap: the reference, the replication and storage pages, the release notes from 8.0 to 17.0, and the articles and landing pages. So were the other repositories of the project:rxdb-server(RxServer),event-reduce,broadcast-channel,jsonschema-key-compression, the quickstart and example apps, and the issue tracker of the Premium plugins. - Checked by running it. Where a claim was cheap to test, it was tested against the published package (memory storage, with and without dev-mode). Those runs settled the collection limit, the SQLite trial's caps, what production RxDB stores without a validator, and how the CRDT plugin keeps its operations. Where the docs and the code disagree, the cell follows the code and a footnote says so.
- Premium is a feature, marked as paid. RxDB Premium (Pro, Pro Plus) is closed source, so its cells follow the docs and the Premium page. They are ⚠️, say
Premiumand name the tier. Where the free core has a trial or a stub of a Premium plugin, the cell says what the free one does. - Changes after 17.6.0 are left out. RxDB's main branch was 108 commits ahead of the tag when this was written. Unreleased changes stay in the data as details, never in a cell's verdict.
- The syncom column is this repository, at the version on the page, checked against its tests and by running it. Its gaps are in the table like RxDB's.
- The data is in the repository.
docs/comparison-rxdb.jsonkeeps every cell with the file, line or page it was decided from.pnpm docs:comparisonrenders it into this page. If a cell is wrong, open an issue or edit the data.
Concepts side by side
The closest syncom counterpart of each RxDB building block. Where there is none, the table says so.
| RxDB | syncom |
|---|---|
createRxDatabase({ name, storage }) | open({ path, fsi }) |
RxStorage (Dexie, memory, Premium OPFS…) | an fsi: five file functions (node:fs, OPFS, IndexedDB, memory) |
addCollections({ todos: { schema } }) | open({ schema: { entities: { Todo: { schema } } } }) → storage.Todo |
primaryKey field of the document | the record id, outside the data (getId, or set(id, data)) |
validate-ajv storage wrapper | an entity schema (Standard Schema, a function, or validator.compile()), on by default |
version + migrationStrategies, per collection | schema.version + schema.migrations, per storage: a lens on read, migrate() to rewrite |
RxDocument | a live Record (storage.record(id)) or a JSON copy (get(id)) |
insert() / upsert() | insert() (id generated) / set(id, data) |
patch() / incrementalPatch() | update(id, delta), or a Record's fields and save() |
a stale _rev throws 409 | swap(id, changes, prev), compared inside the write queue |
$inc (merges only with the crdt plugin) | inc(id, field, n), merges always |
$addToSet / $pullAll | add() / remove(): an observed-remove set |
remove() → _deleted, then the cleanup plugin | delete() → a tombstone; purge(id) erases the payload from the log |
collection.find(query).exec() | collection.select(query).fetch() |
findOne(id) | get(id) / record(id) |
sort, skip, limit | order() / desc(), offset(), limit() |
schema indexes | entity index, and unique |
query.$ | cursor.watch(listener) |
doc.$ / collection.$ | a Record's update event / the storage's change event |
| Premium full-text search (FlexSearch) | $text with createSearch() from @alexify/syncom/search |
preInsert / preSave / preRemove hooks | beforeWrite(ctx) |
postInsert / postSave / postRemove hooks | afterWrite(ctx, op) |
replicateRxCollection({ pull, push }) | storage.sync.addNode({ url }) over an injected link |
| RxServer, or your own endpoints | storage.sync.unit(), mounted on a wrpc server by any node |
conflictHandler | none: fixed per-field rules; beforeApply can skip a peer's op |
pull.modifier / push.modifier | beforeApply (a verdict, no rewrite) / none: scopes decide what leaves |
RxServer authHandler, queryModifier, changeValidator | unit({ authorize }), scopes, the entity validators and beforeApply |
schema encrypted: ['field'] + password | { encrypted: true } per record: sealed for this node, never synced |
multiInstance + leader-election | one storage per directory (lock); lock: 'wait' hands it over |
RxError with a code (messages in dev-mode) | SyncomError with a code (messages always) |
The matrix
✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, Premium (paid), with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. The RxDB column is rxdb 17.6.0 (npm latest, October 2026), decided from its source code at that tag, from runs of the published package and from every page of rxdb.info; Premium plugins are closed source, so their cells follow the docs. The syncom column is this repository.
Package and platform
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Package and license | ||
| Version compared | 0.0.1 (@alexify/syncom) | 17.6.0 (rxdb), npm latest since 2026-10-05 |
| Release status | pre-release, not on npm yet | stable; on npm since 2016, 652 versions |
| License of the core | MIT, the whole package | Apache-2.0 |
| License of the server part | MIT: hosting (sync.unit()) is in the same package | ⚠️ RxServer (rxdb-server) is SSPL; its Fastify and Koa adapters are Premium (Pro Plus) |
| Paid tiers and price | — none | Premium: Pro from $99/month, Pro Plus from $239/month, billed yearly; Enterprise custom |
| What only Premium has | — no split: every feature is in the MIT package | ⚠️ Pro: OPFS, IndexedDB, SQLite, filesystem storages, WebCrypto encryption, fulltext search; Pro Plus: worker, sharding, logger… |
| Premium license terms | — MIT: no term, token or usage limit | ⚠️ yearly term license: use must stop when it lapses; not inside a library1 |
| Open collections limit | ✅ none | ⚠️ free core: 13 open collections per JavaScript process; Premium lifts it2 |
| Calls to the vendor | none: no network code beyond the injected transport | ⚠️ dev-mode loads a hidden rxdb.info iframe; WebRTC defaults to signaling.rxdb.info; Premium install reports to the vendor3 |
| Dependencies, build and size | ||
| Runtime dependencies | ✅ 0: devDependencies only | 24 direct plus the required peer rxjs: 74 packages installed |
| Language and module format | JavaScript, CommonJS, no build step; src/ ships as is | TypeScript, built by Babel into CommonJS and ESM; sideEffects: false |
| Entry points | root plus 4 subpaths: /wrpc, /idb, /search, /contracts | 54 subpath exports, one per plugin (rxdb/plugins/*) |
| TypeScript types | ⚠️ hand-written .d.ts, tsd-tested; collection names typed, record data unknown | ✅ generated from the source; document type inferred from the schema |
| Browser bundle, minimal store | 41.9 KB min+gzip: the browser entry, the whole engine | 80.4 KB min+gzip: createRxDatabase with Dexie; 44.9 KB with localStorage |
| Browser bundle with sync | 42.6 KB min+gzip with the wrpc link adapter | 90.1 KB min+gzip with Dexie and replicateRxCollection |
| Size budget in CI | ✅ pnpm size fails CI when the browser entry passes 42 KB | ⚠️ CI prints a webpack build size; no limit in 17.6.0 |
| Runtimes | ||
| Node.js | ✅ ≥22; persists with node:fs; CI on Node 22 and 24 | ⚠️ ≥20; free disk persistence only via MongoDB or FoundationDB servers, or the capped SQLite trial4 |
| Deno | ❌ not tested; engines names Node.js only | ✅ rxdb/plugins/storage-denokv; CI runs the Dexie and DenoKV suites on Deno 2.6.5 |
| Bun | ❌ not tested | ⚠️ CI runs the memory-storage suite on Bun 1.4.2; no Bun-specific storage |
| Browser persistence | ✅ OPFS (default), IndexedDB (/idb), memory | ✅ free: Dexie (IndexedDB), localStorage, memory; native IndexedDB and OPFS Premium (Pro) |
| Browser requirements | secure context, OPFS, Web Crypto with Ed25519 and RSA-OAEP | crypto.subtle (secure context) or your own hashFunction; some bundlers need a global shim |
| Web Worker and SharedWorker | 🔜 a storage served from a worker to its tabs is the planned follow-up | ⚠️ Worker and SharedWorker storages are Premium (Pro Plus); the free core runs inside a worker |
| React Native | ❌ not a target: no React Native backend or Web Crypto shim | ⚠️ Premium SQLite or Expo Filesystem storage; the free SQLite trial is capped5 |
| Expo | ❌ not a target | ⚠️ Expo Filesystem storage is Premium (Pro); free: expo-sqlite through the SQLite trial |
| Capacitor and Ionic | ❌ not tested; a Capacitor fsi adapter is yours to write | ⚠️ Premium SQLite via @capacitor-community/sqlite; free: localStorage or Dexie in the WebView |
| Electron | ❌ not documented or tested; no IPC bridge | ✅ rxdb/plugins/electron: storage in the main process, databases in renderers over IPC |
| Tauri | ❌ not documented or tested | ⚠️ SQLite storage with getSQLiteBasicsTauri(): Premium, or the capped free trial |
| Flutter | ❌ none | ⚠️ rxdb/plugins/flutter runs RxDB in QuickJS; its recipe needs the removed LokiJS storage6 |
| Tabs and instances | ||
| Several tabs on one database | ⚠️ one tab per storage: open() holds a Web Lock, a second tab gets LOCKED7 | ✅ multiInstance: true (default): every tab opens it; changes cross over BroadcastChannel |
| Leader election | ⚠️ no election API; lock: 'wait' hands the storage to the next tab | ✅ rxdb/plugins/leader-election: waitForLeadership(), isLeader(); Web Locks when available |
Storage
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Backends | ||
| Storage contract | ✅ fsi: five file functions — read, write, exists, remove, list | ✅ RxStorage → RxStorageInstance: bulkWrite, Mango query, count, changeStream, cleanup… |
| Writing your own backend | ✅ five functions; tests/suites/fsi.js is the contract as a test | ✅ implement RxStorageInstance; the storage suite runs from a clone of the repo |
| In-memory | ✅ createMemoryFsi(): a Map of files; Node and browser | ✅ storage-memory (free): plain objects, binary-search indexes; Node and browser |
| IndexedDB | ✅ @alexify/syncom/idb createIdbFsi(): each file a row keyed [dir, name] | ✅ storage-dexie (free, Dexie.js); native storage-indexeddb is Premium (Pro)8 |
| OPFS | ✅ the default browser backend; async createWritable(), main thread or worker | ⚠️ Premium (Pro) storage-opfs: in a worker (sync handles) or getRxStorageOPFSMainThread() |
| localStorage | ❌ none shipped; an fsi over it would be five functions | ✅ storage-localstorage (free): a key per document, a JSON array per index; browser |
| Node.js filesystem | ✅ the default under Node: node:fs/promises, plain JSON files | ⚠️ Premium (Pro) storage-filesystem-node; Node and Electron |
| SQLite | ❌ none | ⚠️ Premium (Pro) storage-sqlite; the free getRxStorageSQLiteTrial() is capped9 |
| Expo / React Native files | ❌ none; not a target, an fsi is injectable | ⚠️ Premium (Pro) storage-filesystem-expo, beta since 17.0.0; needs expo-opfs |
| MongoDB as storage | ❌ none | ✅ storage-mongodb (free), Node; no attachments10 |
| FoundationDB | ❌ none | ✅ storage-foundationdb (free), Node with the native client; one process per cluster |
| Deno KV | ❌ none | ✅ storage-denokv (free), Deno only11 |
| LokiJS / PouchDB storages | — no legacy backends | ❌ removed: PouchDB in 14.0.0; LokiJS and memory-synced in 16.0.012 |
| Free-tier limits on storage | ✅ none: every backend is in the MIT package | ⚠️ SQLite trial capped; Dexie prints a Premium notice; no trial of other Premium storages |
| Wrappers and offloading | ||
| Stacking storage wrappers | ⚠️ an fsi can wrap an fsi (bytes only); document transforms are hooks | ✅ wrapRxStorageInstance: validation, key compression, encryption, attachment compression stack |
| Remote storage (IPC, WebSocket) | ❌ none; a peer replicates a copy instead | ✅ storage-remote over any message channel; storage-remote-websocket; Electron IPC |
| Sharding | ❌ none | ⚠️ Premium (Pro Plus) storage-sharding; schema sharding: { shards, mode } |
| In-memory layer over a disk store | ❌ none; cache.max entries and the catalog stay in memory | ⚠️ Premium (Pro Plus) storage-memory-mapped; memory-synced removed in 1613 |
| What opening loads | the catalog (every id, indexed values) and the retained log, into memory | lazy per collection; Premium (Pro Plus) meta optimizer keeps internal metadata in localStorage |
| Durability and recovery | ||
| Write durability | ✅ log batch first, then the record; durable: true fsyncs each file (node:fs)14 | ⚠️ the storage's job; contract: each document atomic, no batch transaction |
| Crash recovery on reopen | ✅ replays the log's unsealed tail; a torn newest batch is dropped | ⚠️ the storage engine's own (IndexedDB, SQLite transactions); no replay in the core |
| Exclusive open | ✅ Web Locks or lock.json (pid); 'fail' → LOCKED, 'wait', false15 | ⚠️ no lock: tabs and processes share a store (multiInstance); single-process storages unguarded |
| Integrity check and repair | ✅ cold reads re-verified against the chain, torn files too (INTEGRITY); repair(id) replays the log16 | ❌ none; attachment digests serve change detection only |
| Quota and eviction handling | ❌ none; past the log append a failed record file is held in memory and reported (UNPERSISTED) | ❌ none in the core; the storage's QuotaExceededError reaches the caller |
| Data on disk | ||
| On-disk format | ✅ plain JSON files: data/<id>.json, log batches, chain blocks, keys | ⚠️ per storage: JSON documents with _rev, _meta (Dexie, localStorage, SQLite, MongoDB); Premium formats undocumented |
| Files written per change | three per local write: log batch, chain block, record (two with chain: false)17 | one document row per write, plus the storage's index entries |
| Compression | ❌ none | ✅ key-compression wrapper (schema-derived short keys); attachments-compression (gzip/deflate) |
| In-memory document cache | ✅ cache: { max: 5000 } entries; a live Record pins its own (WeakRef) | ✅ DocumentCache per collection: RxDocuments by revision, held by WeakRef |
| Attachments / binary data | ❌ none: JSON records only, no binary type | ✅ attachments plugin: Blobs per document, putAttachment(), getData()18 |
| Local (non-replicated) documents | ⚠️ only encrypted records stay local; no plain local documents | ✅ local-documents: insertLocal(), getLocal$(); not replicated, no schema |
| Tombstone cleanup | ❌ none: a tombstone keeps its file (data emptied) and catalog row | ✅ cleanup plugin: purges tombstones after 31 days, once replications are in sync |
| Delete a whole database | ❌ no API; remove the directory yourself | ✅ db.remove(), removeRxDatabase(name, storage), collection.remove() |
| Moving data | ||
| Migrate between backends | ❌ no tool; the same JSON files on every backend | ✅ migrateStorage() (free): moves live documents batch by batch into a new database19 |
| Backup to files | ❌ no API; the Node directory is plain JSON, copy it closed20 | ⚠️ backup plugin: Node only, one-time or live, a document.json per document; no restore |
| JSON export / import | ⚠️ snapshot() pages out records with meta, tombstones too; mergeSnapshot() joins them | ✅ json-dump: exportJSON() / importJSON(), schema-hash checked, attachments optional21 |
Data model and writes
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Documents and collections | ||
| Data model | JSON records addressed by id; collections optional ($type) | JSON documents; every collection has a JSON Schema |
| Records without a schema | ✅ no schema needed; {} is a bare collection | ⚠️ schema required; an open type: 'object' sub-field, RxState or local docs |
| Collection names | ✅ any non-empty string the storage does not answer to (get, close… refused at open()) | ✅ never shadows database members; regex checked in dev-mode |
| Field names | ✅ any JSON key; __proto__ ignored, $type reserved | ⚠️ letters, digits, _; no leading _; no RxDocument member names |
| Identity | ||
| Primary key | ✅ the record id, outside the data; the validator sees it under idField | ✅ a top-level string field named by primaryKey (_id allowed) |
| Composite primary key | ❌ none; a composite unique key instead | ✅ primaryKey: { key, fields, separator }, filled on insert |
| Id generation | ✅ getId: UUID by default; sync or async generator of your own | ❌ the caller supplies the key; a composite key is derived |
| Id format rules | ✅ [A-Za-z0-9_@-][A-Za-z0-9._@-]{0,127}, checked on every write and peer op | ⚠️ string with maxLength; values checked only by a validator and dev-mode22 |
| Immutable fields | ⚠️ only $type (the collection) and the id | ⚠️ final: true; the primary key is final — enforced in dev-mode only23 |
| Schema and validation | ||
| Schema format | ✅ none of its own: a Standard Schema, a function, or validator.compile() | ✅ JSON Schema (draft-04 subset) plus primaryKey, indexes, final, ref |
| Validators | ✅ whatever you inject: metaschema, zod, valibot, arktype, ajv, TypeBox; none bundled | ✅ validate-ajv, validate-z-schema; validate-is-my-json-valid unsupported24 |
| Validation on by default | ✅ on for every entity with a schema; validate: false opts out | ⚠️ off until the storage is wrapped; dev-mode refuses to start without it25 |
| Validator can transform data | ✅ its value is written: defaults, aliases, stripped keys, decoders | ❌ validators return errors only; the data is written as given |
| Validate without writing | ✅ storage.check(entity, data) returns the validated value | ⚠️ getValidator(schema) from the validate plugin; no collection method |
| TypeScript types of the data | ❌ records are unknown; only collection names are typed | ✅ ExtractDocumentTypeFromTypedRxJsonSchema from an as const schema; typed collections |
| Default values | ✅ from the validator (metaschema default, zod .default()), folded into the op | ⚠️ default on top-level fields, filled by insert and upsert |
| Required fields | ✅ as the validator declares them | ✅ JSON Schema required; the primary key always |
| Nested objects | ⚠️ stored and validated; merged as one top-level register26 | ✅ nested schemas; dotted paths in update operators and queries |
| Schema versions and migration | ||
| Schema versions | ✅ schema.version on every op (v) and record (meta.v); snapshot hash | ✅ version per collection; a changed schema without a bump throws DB6 |
| Migrating stored data | ✅ lens on read (migrations, then the validator); migrate() rewrites, idempotent | ✅ migrationStrategies per version, at open; returning null drops a document |
| Old data before migrating | ✅ read through the lens; writes allowed | ❌ invisible until migrated; writes throw COL25 |
| A peer on another schema version | ✅ the op carries v; lens for older, ahead: 'apply' | 'hold' for newer | ⚠️ no version on documents; run the same migration on server and clients |
| Writes | ||
| Bulk writes | ❌ none; one op per call (a batch is Promise.all of insert()) | ✅ bulkInsert, bulkUpsert, bulkRemove with per-document success and error lists |
| Upsert | ✅ set(id, data) replaces the record wholesale; Collection.set() | ✅ upsert(), bulkUpsert(), incrementalUpsert() |
| Insert only if absent | ❌ none; has() then set() is two calls | ✅ insertIfNotExists() returns the existing document; insert() throws 409 |
| Update some fields | ✅ update(id, delta); record.field = v then save() | ✅ patch(), incrementalPatch() |
| Remove one field | ❌ only by a whole-record set(); undefined in a delta is dropped | ✅ patch({ field: undefined }), $unset |
| Update by function | ❌ no modify(fn); get() then swap() in a retry loop | ✅ modify(fn); incrementalModify(fn) re-runs on the latest state |
| Update operators | ⚠️ typed ops only: update, inc, add/remove; no $push, $min, $rename | ✅ update plugin (mingo): $set, $inc, $push, $addToSet, $pullAll, $min… |
| Update or delete by query | ❌ none; select().fetch(), then one write per row | ✅ find().update(), .patch(), .modify(), .remove() |
| Compare-and-swap | ✅ swap(id, changes, prev) compares field values inside the write queue | ✅ every non-incremental write checks _rev; a stale one throws 409 CONFLICT |
| Concurrent writes on one node | ✅ one mutation queue; no local conflict errors | ⚠️ a stale patch/modify/update throws 409; the incremental* methods retry |
| Multi-record transactions | ❌ none; every write is one op on one record | ❌ none; one document write is the atomic unit |
| Change and delete in one write | ❌ delete() carries no data; two ops | ✅ set _deleted: true inside modify() or update() |
| Counters, sets and lists | ||
| Counters that converge | ✅ inc(): one running sum per origin27 | ⚠️ $inc is local; it converges only under the crdt plugin |
| Sets | ✅ add()/remove(): observed-remove set, read sorted | ⚠️ $addToSet/$pullAll; crdt replays them in creator order, not observed-remove |
| Ordered lists that merge | ❌ no list type; an array is a register or a set | ⚠️ crdt plugin replays $push; no positional insert |
| Collaborative text | ❌ none | ❌ none |
| Merge model | ||
| Conflict-free model | ✅ built into every write: register, counter or OR-set per field | ⚠️ opt-in crdt plugin per schema; otherwise a conflict handler |
| Concurrent edits to different fields | ✅ both kept: last writer wins per top-level field | ⚠️ the default handler keeps the master's whole document; merging needs a handler or crdt |
| Who wins a concurrent overwrite | the later stamp (ts, origin); a node's clock never falls behind what it saw | crdt: op position, then storage-token order, not time; otherwise the master |
| Where the merge history lives | ✅ op log beside the records, with retention; records keep per-field stamps | ⚠️ crdt: every op stored inside the document, never compacted28 |
| Writes that bypass the merge model | ✅ none: every write is an op; mixing field kinds throws FIELD_KIND | ⚠️ modify() is refused, but upsert()/incrementalUpsert() replace the op history29 |
| Conditional operations | ❌ none that replicate; swap() is a local check | ✅ crdt: selector with ifMatch/ifNotMatch, re-evaluated on every replay |
| Delta helpers | ✅ diff, merge, apply: pure functions; numbers add, arrays union30 | ❌ none; mergeCRDTFields merges the plugin's own field only |
| Revisions, deletes and document objects | ||
| Per-record metadata | meta: per-field [ts, origin], reset stamp, tombstone, counters, sets, v | _rev (height-token, local), _meta.lwt, _deleted, _attachments |
| Delete | ✅ tombstone with empty data; a newer write revives the record | ✅ _deleted: true with the full data until cleanup; re-insert revives |
| Document objects | ✅ record(id): one live, editable Record per id, refreshed on any change | ✅ RxDocument: read-only snapshot per revision; getLatest(), $ |
| Same object for the same record | ✅ one Record per id while referenced (WeakRef); get() returns new copies | ✅ one RxDocument per revision while referenced (WeakRef cache) |
| Editing returned data | ✅ JSON copies in and out; editing them changes nothing | ⚠️ deep-frozen in dev-mode only; production toJSON() shares nested objects31 |
| Relations and higher-level APIs | ||
| ORM methods | ❌ none | ✅ statics, methods, attachments per collection |
| References between records | ⚠️ a Record turns any string equal to a known id into a Reference32 | ✅ schema ref with populate() or the field_ getter; one or many |
| Persistent app state | ❌ none | ✅ state plugin (RxState): schemaless JSON, modifier writes, replicable |
| Derived collections | ❌ none; afterWrite, change and watch() are not checkpointed | ✅ pipeline plugin: leader-run handler, checkpointed, blocks the destination |
| Stored procedures | ⚠️ @alexify/syncom/contracts: arrow functions stored in the chain, run by new Function33 | ❌ none |
Queries and reactivity
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Query language and operators | ||
| Query language | ✅ Mango-shaped selector: collection.select(query), then cursor builders | ✅ Mango query { selector, sort, skip, limit, index }, matched by mingo |
| Chained query builder | ⚠️ cursor chains order(), desc(), limit(), offset(), filter(), projection(); the predicate is JSON only | ✅ query-builder plugin: where('f').gt(5).sort('-f').limit(n) |
$eq $ne $gt $gte $lt $lte | ✅ all six; $gt…$lte hold only within one kind (number, string, boolean) | ✅ all six (mingo) |
$in $nin $all | ⚠️ $in, $nin; no $all (an $and of equalities instead) | ⚠️ $in, $nin; no $all (an $and of equalities instead) |
$exists $type | ⚠️ $exists only (null counts as present); no $type | ✅ $exists; $type (null, boolean, number, string, array, object) |
$elemMatch $size | ⚠️ a scalar matches any element; no $elemMatch, no $size (filter() instead) | ✅ element match, $elemMatch (objects or scalars), $size |
$regex $options | ✅ RegExp or source string, $options flags; 1000 characters at most; g/y dropped | ✅ string with $options; a RegExp instance is refused in dev-mode (QU16)34 |
$and $or $nor $not | ✅ $and, $or, and $not over a whole query; no $nor, no field-level $not | ✅ $and, $or, $nor; $not on a field only |
$mod | ❌ not supported (filter() instead) | ✅ $mod: [divisor, remainder] |
| Arbitrary JS predicate | ✅ filter(fn) over the loaded row, applied before limit() | ❌ no $where or $expr; filter the results in app code |
| Nested fields (dotted paths) | ✅ in queries, order(), projection(), index and search; items.0.n reaches into arrays | ✅ dot notation in selectors, sort (tags.0) and indexes |
| Malformed query detection | ✅ unknown operator or bad argument: TypeError at select(); field names are not checked | ⚠️ unknown operators throw; field names, RegExp, undefined and extra keys only in dev-mode |
| Typed queries and rows | ⚠️ MangoQuery is untyped; rows are QueryRow (Record<string, unknown>) | ✅ MangoQuery<RxDocType> checks paths and values; results are typed RxDocuments |
| Immutable query objects | ❌ builders change the cursor in place (one() leaves it as it was) | ✅ every builder returns a new RxQuery |
| Test a document against a query | ❌ the compiled predicate is internal (compileQuery is not exported) | ✅ query.doesDocumentDataMatch(doc) |
| Reading results | ||
| Sort | ✅ order()/desc(): several keys, any field, dotted; stable | ✅ sort: [{ f: 'asc' | 'desc' }]: several keys, nested; primary key appended |
| Sort served by an index | ❌ no sorted index: every candidate is read, then ordered | ✅ an ascending sort that matches the index walks it and stops at skip + limit35 |
| Top-k for an unindexed order | ✅ TopK keeps the best offset + limit rows as candidates stream by | ⚠️ sorts every match, then slices |
| Skip and limit | ✅ offset(n), limit(n); an unordered cursor stops reading at enough matches | ✅ skip, limit; findOne() refuses a limit (QU6) |
| Streaming cursor / keyset paging | ❌ for await yields an already fetched array; keyset paging by hand | ❌ exec() returns the whole page; keyset paging by hand ($gt on the sort key) |
| Count queries | ✅ count(query) for any query; from the catalog alone when every field is indexed | ⚠️ a selector no index satisfies throws QU14 on Dexie (every storage in dev-mode) unless allowSlowCount: true |
| Find by id | ✅ get(id) (a copy), record(id) (a live Record); hasRecord(id); a collection's get only its own | ✅ findOne(id): document cache first, then findDocumentsById() |
| Find many by id | ❌ no batch get; select({ id: { $in } }) reads the whole collection | ✅ findByIds(ids) → Map, observable like any query |
| First match | ✅ cursor.one() → the row or null | ✅ findOne(query); exec(true) throws when nothing matches |
| Projection (select fields) | ✅ projection(fields); answered from the catalog when they are indexed | ❌ always whole documents, by design; a fields key is QU11 in dev-mode |
| What a query returns | plain JSON copies { id, ...data }, the lens applied; new objects per fetch() | RxDocument instances, one per revision, shared across queries |
| Aggregation and group-by | ❌ none; compute over fetch() rows | ❌ none, by design |
| Joins and cross-collection queries | ❌ a cursor reads one collection; Reference.record() follows one id | ❌ one collection per query; populate() follows one ref |
| Query on encrypted data | ❌ an { encrypted: true } record is listed, never a query row | ❌ encrypted fields cannot be queried; an $eq on one silently matches nothing |
| Indexes and planning | ||
| Secondary indexes | ✅ entity index: ['field', …], or read off a metaschema schema | ✅ schema indexes: ['f', ['a', 'b']] |
| Index kind | ⚠️ equality posting lists; ranges, prefixes and sorts do not use them36 | ✅ sorted keys: $eq, $gt…$lte and $in (min to max) bound the scan |
| Compound indexes | ⚠️ only for unique keys; queries intersect single-field postings | ✅ any index is a field list; _deleted and the primary key are added |
| Array (multi-entry) indexes | ✅ an indexed array is posted under each element | ❌ refused in dev-mode (SC36); an RxPipeline into a mapping collection instead37 |
| Indexable values | ✅ any JSON value, keyed canonically (object keys in any order) | ⚠️ strings with maxLength, numbers with minimum/maximum/multipleOf, required booleans |
| Where indexes live | in memory (the catalog), saved to index/catalog.json, caught up from the log at open | in the storage: IndexedDB indexes on Dexie (disk), sorted arrays on memory |
| Adding or changing an index | ✅ edit index; the catalog rebuilds once at the next open | ⚠️ a schema change: bump version and migrate, or addCollections() throws DB6 |
| Query planner | ✅ indexed equalities narrow the candidates before any record is read | ✅ rates one key range per index, takes the best; index forces one |
| Several indexes in one query | ✅ postings intersect over $and, union over an $or whose branches are all indexed | ❌ one index per query; an $or scans a whole index |
| Index-only (covered) queries | ✅ query, order and projection all indexed: answered from the catalog, no record read | ⚠️ count() on an index-satisfied selector only; find() always reads documents |
| Plan inspection | ❌ no explain API; the plan is private to the cursor | ✅ query.getPreparedQuery().queryPlan: index, key range, sortSatisfiedByIndex |
| Index advisor | ❌ none | ⚠️ Premium (Pro Plus): findBestIndex(), run at build time on test data |
| Query cache | ❌ none; each fetch() re-runs, records come from the bounded entry cache | ✅ QueryCache: equal queries share one RxQuery; about 100 unobserved ones kept |
| Unique constraints | ✅ unique fields and composites; a local write is refused, a peer's clash is a conflict event | ❌ primary key only |
| Search | ||
| Full-text search | ✅ $text with createSearch() (@alexify/syncom/search): trigram TF-IDF, typo-tolerant | ⚠️ Premium (Pro): FlexSearch plugin, addFulltextSearch() then flexSearch.find(text) |
| Text search inside a query | ✅ $text combines with indexed equalities, order(), limit(), count(), watch() | ❌ flexSearch.find() is a separate call; no $text operator |
| Vector / similarity search | ❌ none | ⚠️ rxdb/plugins/vector: four distance functions; nearest-neighbour search is app code |
| Reactivity | ||
| Live queries | ✅ cursor.watch(listener): runs now, then after every change to the collection | ✅ query.$: the current result on subscribe, then each new result |
| Live update without re-querying | ❌ every wake re-runs the whole cursor | ✅ EventReduce derives the new result from the change events |
| Emits only when the result changed | ❌ the listener runs after any change to the collection, same rows or not | ✅ an unchanged result is not emitted |
| Live count | ❌ no; watch() hands rows (rows.length) | ✅ count().$, adjusted from each change event without a re-count |
| Collection change stream | ⚠️ storage-wide change event (op, via); no per-collection stream | ✅ collection.$, insert$, update$, remove$; database.$ |
| Document observable | ✅ a live Record emits update (data, delta), null once deleted | ✅ doc.$, doc.get$(path) / doc.field$, doc.deleted$ |
| Reactivity primitive | ⚠️ callbacks and events; no observable or signal adapter | ✅ RxJS observables ($); a reactivity factory turns them into signals or refs ($$) |
| Peer writes in live results | ✅ a peer's ops and snapshot pages wake watch(); a batch is one run | ✅ replicated writes go through the collection's event stream, so $ updates |
Replication
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Model and protocol | ||
| Replication model | operation log; version vectors per origin and scope; pages of ops | document state: pull by checkpoint, push with assumedMasterState (fork and master) |
| What crosses the wire | ops as positional packOps arrays, signed; a set carries only its delta | whole documents with _deleted; a push row carries two whole states |
| Intermediate writes replicated | ✅ every retained op travels, intermediate writes included | ❌ latest state per document; intermediate writes collapse before a push |
| Protocol version check | ✅ PROTOCOL 1 in hello; another version is refused with 400 | ❌ none in the protocol; checkpoint and payload are the backend's |
| Schema check at connect | ✅ hello swaps { version, hash }; same version, other hash emits schema drift | ⚠️ RxServer only: schema version in the URL; older clients get 426 (outdatedClient$)38 |
| Sync with an existing backend | ❌ none: the far end must be a syncom node hosting unit() | ✅ any backend: pull, push and stream handlers over your own database |
| Writing your own transport | ✅ SyncLink: call, subscribe with lastEventId, on/off, close; optional stream | ✅ replicateRxCollection: pull.handler, push.handler, pull.stream$ with RESYNC |
| Round trips to catch up | hello, then one push per 500 ops, then one changes subscription | pulls until a short page; one push per 100 documents; a re-push per conflict |
| Collections in one connection | ✅ one link carries every collection of the storage, filtered by scope | ⚠️ one replication per collection, each with its own requests and stream |
| Sync state kept on the client | a cursor per peer, the version vector, the op log within retention | per replication: two checkpoints and the last master state of every document |
| Topology | ||
| Topology | mesh: any node dials and hosts; hubs dial each other | a star of fork–master pairs; WebRTC peers pair up in a mesh |
| Peers without a central server | ✅ any node hosts unit(); peers dial each other directly | ⚠️ WebRTC only; each pair has a hash-chosen master and a fork39 |
| Multi-hop relay | ✅ ops relayed hop by hop; the version vector drops what a node has | ✅ through the local store: a second replication pushes what the first pulled |
| A browser serves data | ✅ unit() on a wrpc PeerHost over a data channel | ⚠️ only as the master of a WebRTC pair; other servers are Node |
| Scaling the server out | ⚠️ hubs dial each other; a client stays on one hub's log | ⚠️ processes share one storage; branch servers replicate to a root40 |
| Peer-to-peer (WebRTC) | ||
| Browser to browser (WebRTC) | ✅ wrpc WrpcPeer/PeerLink and PeerHost (@alexify/wrpc/webrtc) | ✅ replication-webrtc (simple-peer), free; tests re-enabled in 17.6.0 |
| Signaling | ✅ wrpc's createSignalingUnit on your hub, or a channel you negotiated | ✅ self-hosted ws server, a public demo server (default), or Nostr relays41 |
| Peers in a room | one link per pair you add (peer.join(room) + addNode); ops relay any path | full mesh: one replication per pair, N(N−1)/2 in all, no cap |
| STUN / TURN servers | your iceServers; wrpc sets none | simple-peer's Google and Twilio STUN by default; no TURN |
| Transports and backends | ||
| WebSocket | ✅ a wrpc WrpcClient through createWrpcLink (@alexify/syncom/wrpc) | ⚠️ replication-websocket: RxDB to RxDB, a Node server, no authentication42 |
| HTTP / Server-Sent Events | ⚠️ no request–response sync; wrpc's SSE transport untested in the repo43 | ✅ your endpoints under replicateRxCollection; RxServer serves pull, push and SSE |
| GraphQL | ❌ none | ✅ replication-graphql; live through graphql-ws; schema and query generators44 |
| CouchDB | ❌ none | ✅ replication-couchdb: _changes long-poll, _bulk_docs with _rev |
| Firestore | ❌ none | ✅ replication-firestore (firebase peer); pull.filter; live via onSnapshot45 |
| Supabase | ❌ none | ✅ replication-supabase: PostgREST compare-and-set; Realtime for live rows |
| Appwrite | ❌ none | ⚠️ replication-appwrite: browser only; updates read then write, not atomic |
| MongoDB | ❌ none | ✅ replication-mongodb (Node): change streams; push in a transaction; replica set |
| NATS JetStream | ❌ none | ✅ replication-nats: a subject per document; updates checked by sequence |
| Google Drive | ❌ none | ⚠️ replication-google-drive, beta: JSON files and a lock file in the user's Drive |
| Microsoft OneDrive | ❌ none | ⚠️ replication-microsoft-onedrive, beta: the same file-and-lock scheme over Graph |
| Conflicts and clocks | ||
| Conflict detection | — no compare step: every op merges by its field's kind | ✅ the master compares assumedMasterState with its own (isEqual, deep-equal) |
| Conflict resolution | built in: per-field LWW by (ts, origin), per-origin counter sums, OR-sets | conflictHandler.resolve on the client; the default keeps the master's document46 |
| Custom merge logic | ❌ none: the rules are fixed; beforeApply can only skip an op | ✅ any function per collection; the crdt plugin brings its own |
| Where conflicts are settled | on every node, by the same deterministic rules | on the client (fork); the server only detects |
| Conflicts reported | ⚠️ unique-key clashes only (conflict); a lost concurrent write is silent | ✅ conflict$: each conflict the server reported, with the handler's output |
| Atomic compare on the server | — nothing to compare: ops commute | ⚠️ per plugin: atomic on RxDB storages, CouchDB, Supabase, MongoDB; not Firestore, Appwrite |
| Causality tracking | version vectors for delivery; per-field stamps; no causal context per record | none across replicas: a compare-and-set against the master's last state |
| Role of peer clocks | decide concurrent register writes; a clock never falls behind what it saw | unused by the protocol; backend checkpoints and LWW handlers bring their own |
| Delivery | ||
| Realtime stream | ✅ built in: the host's changes subscription pushes ops as they land | ✅ pull.stream$, yours or a plugin's; RESYNC when events may be missed |
| Resume after a disconnect | ✅ the link resumes the subscription from its last event id; push by vector | ✅ a checkpoint pull (RESYNC) after each reconnect; the stream is not resumed |
| Resume after an app restart | ✅ cursor, remote identity and vector are on disk; no bootstrap | ✅ checkpoints stored per replicationIdentifier; a new state with the same id continues |
| Reconnect and backoff | ✅ the link's: wrpc reconnects with backoff and a heartbeat; syncom greets again | ⚠️ each plugin's own; a custom stream$ must reconnect and emit RESYNC |
| Failed requests | ✅ a failed push leaves the peer behind, caught up later; backoff 1 s to 60 s | ✅ each pull or push error goes to error$, retried after retryTime (5 s), forever |
| Duplicate and out-of-order delivery | ✅ duplicates dropped by the vector; an op ahead of its stream dropped as a gap | ✅ states, not ops: pages ordered by checkpoint, repeats harmless |
| Offline writes | ✅ own ops kept until the registered nodes hold them; pushed when a peer is back47 | ✅ local writes; on reconnect the latest state of each changed document is pushed |
| Bootstrap a new replica | ✅ replays the host's log, or past its retention a streamed snapshot | ✅ a checkpoint iteration from scratch over the backend's current documents |
| Back after a long outage | ✅ a snapshot of its scopes once the host's log no longer covers it | ✅ the same checkpoint pull; the backend must keep soft-deleted documents |
| Batch and page sizes | pageSize 500 ops per push and per page; 500 records per snapshot page | pull.batchSize, push.batchSize, 100 each; plugin defaults differ |
| Memory bounds | ✅ per-peer outbox capped (maxOutbox 10 000); pages applied one at a time | ⚠️ no explicit caps; four push batches in flight; queued changes coalesce per document |
| Partial replication | ||
| Partial replication | ✅ per-record scopes; the host bounds a peer's reads and writes by the record's scope | ⚠️ one replication per scope, filtered by your backend; RxServer queryModifier |
| Scoped realtime stream | ✅ one subscription serves the granted scopes; other scopes' ops are skipped | ⚠️ one stream per replication, scoped by your backend |
| Access lost after sync | ❌ no revocation: the peer keeps what it received | ❌ no signal: the client keeps its copy; RxServer then refuses its pushes |
| Pull-only or push-only | ❌ every link pushes and subscribes; scopes bound both directions alike | ✅ omit push or pull; pull-only stores no per-document meta |
| Deletes and attachments | ||
| Deletes over replication | ✅ a delete op leaves a tombstone on every node; a purge op erases | ✅ _deleted: true with the whole document; the backend must keep it |
| Attachments over replication | ❌ none: records are JSON, with no binary type | ⚠️ in the protocol; only Google Drive and OneDrive carry them intact48 |
| Control and observability | ||
| Start, pause, stop | ✅ sync.start()/stop() for all peers; enableNode(url, false) per peer | ✅ per replication start(), pause(), cancel(), remove(); live: false runs once |
| Waiting until in sync | ⚠️ sync(url?) resolves once pushed; nothing awaits the inbound side | ✅ awaitInitialReplication(), awaitInSync(), awaitDocumentPushed(doc) |
| Forcing a re-sync | ✅ sync() pushes what peers lack; enableNode off and on greets again | ✅ reSync() runs a checkpoint pull; emitEvent() feeds the stream by hand |
| Replication status and events | ✅ peers(): connected, cursor, pending, behind, stuck, lastError; hosted(); events | ✅ received$, sent$, error$, active$, canceled$, conflict$ per replication |
| Replication with several tabs | ⚠️ one tab owns the storage and its sync; others get LOCKED or wait | ✅ only the elected leader tab replicates (waitForLeadership: true); others see its writes49 |
| Server product | ||
| Server product | ✅ sync.unit(): four procedures for any wrpc router, in the same MIT package | ⚠️ RxServer: the separate rxdb-server package (SSPL), over an RxDatabase |
| Where the server runs | ✅ wherever a wrpc router runs: Node servers, a browser PeerHost | ⚠️ Node, through the Express adapter; Deno, Bun and Electron claimed, untested |
| HTTP framework adapters | ✅ any wrpc host (MIT): its own Server, uws, fastify, express | ⚠️ Express free (SSPL); Fastify and Koa are Premium (Pro Plus) |
| What the server serves | hello, push, snapshot, changes (a resumable subscription) | per collection pull, push, pullStream (SSE); REST query, get, set, delete |
| Queries for thin clients | ❌ none: a client replicates its scopes and queries locally | ✅ RxServer REST endpoint and client-rest; live results over SSE |
| Server-side storage | a full syncom node: its own log and records over its fsi | any RxStorage of the server's RxDatabase: memory, MongoDB, FoundationDB… |
Security and integrity
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Encryption | ||
| Encryption at rest | ⚠️ per record ({ encrypted: true }) or the whole storage; such records never sync50 | ⚠️ per field (schema encrypted: [...]): free encryption-crypto-js; WebCrypto is Premium (Pro) |
| What is encrypted | the record's whole data; its id, collection and timestamps stay readable | the listed fields only; primary key, other fields, _meta, _rev stay plain |
| Cipher and integrity | ✅ AES-256-GCM with a fresh key per record, wrapped by RSA-OAEP-256 | ⚠️ free: AES-256-CBC, MD5 key derivation, no MAC; AES-GCM selectable in Premium51 |
| Key kept off the disk | ❌ the RSA private key is a file in <path>/keys/, beside the data52 | ⚠️ password given at createRxDatabase, kept in memory; an unsalted SHA-256 of it is stored53 |
| Changing the key or password | ❌ no rotation API; a replaced key pair cannot open sealed records | ❌ not possible; migrate into a new database, or wrap a meta-password |
| Encrypted attachments | — no attachments | ✅ schema attachments: { encrypted: true } |
| Encrypted data in backups and dumps | — no backup or export API; snapshot() leaves encrypted records out | ⚠️ the backup plugin and exportJSON() write encrypted fields decrypted |
| Encrypted data and replication | encrypted records stay local: no op, no snapshot, no peer copy | encrypted fields replicate decrypted; the backend receives plaintext |
| End-to-end encryption | ⚠️ recipe: seal fields in beforeWrite, open in afterRead (wrpc's HPKE sealer)54 | ❌ none; replication reads the decrypted documents |
| Signatures, identity and integrity | ||
| Signed changes | ✅ Ed25519 per op: the header plus a salted payload digest55 | ❌ none; Nostr keys sign WebRTC signaling, not documents |
| Node identity | ✅ the node id is its Ed25519 public key; a swapped key is IDENTITY_MISMATCH | ⚠️ random tokens (storageToken, instance token); a key only through the Nostr handler |
| Tamper-evident history | ✅ hash-linked chain of what this node applied, unsigned (optional)56 | ❌ none; _rev is a height plus the writer's instance token |
| Access control on the serving side | ||
| Authentication where data is served | ✅ unit({ authorize }) gates hello; access: 'session' refuses everyone by default57 | ✅ RxServer authHandler(headers), re-run before each streamed event58 |
| Peer validation (P2P) | ⚠️ host authorize; dialer beforeConnect, beforeBootstrap; key possession not proven | ✅ isPeerValid(peer); the Nostr handler verifies each peer's public key59 |
| Per-client read filtering | ✅ per-record scopes (sync.scope); a peer reads only the scopes hello granted60 | ✅ RxServer queryModifier(authData, query); serverOnlyFields hidden from clients |
| Per-client write rules | ⚠️ ops outside granted scopes refused, by the op's scope and its record's; beforeApply({ op, peer }) can skip one61 | ✅ RxServer changeValidator(authData, change): one refused row is 403 for the push |
| Server re-validates a client's write | ✅ the host runs the entity validators on every peer op (OP_INVALID) | ⚠️ only if the server's storage is wrapped in a validate-* plugin62 |
| A server can reject a write before it spreads | ⚠️ out-of-scope (by the op or its record), unsigned and malformed ops stop at the host; one its validator or beforeApply skips is relayed63 | ✅ RxServer is the master: a refused push reaches no client64 |
| Rate and size limits | ✅ unit({ limits }): opsPerPush, bytesPerPush (413), opsPerSecond (429) | ❌ none in RxServer; $regex queries refused against ReDoS |
| Erasure, history and audit | ||
| Purge: the payload leaves history | ✅ purge(id): a purge op that wins at any stamp, then a scrub of the op log65 | ⚠️ cleanup plugin drops tombstones after minimumDeletedTime (31 days)66 |
| Erasure across replicas | ✅ the purge op replicates; every peer that takes it scrubs its own log67 | ❌ cleanup is local; a delete replicates as a tombstone with the full data |
| History / time travel | ⚠️ op log within retention (storage.log.entriesOf(id)); no read-at-version | ⚠️ latest revision only; the crdt plugin keeps every op in the document68 |
| Who made a change | ✅ every op names its origin key and an actor, kept in the log69 | ❌ none; crdt ops carry the writer's storage token and a time |
| Input hardening | ||
| Prototype pollution | ✅ own keys only; __proto__ is never a field | ✅ mingo and dot-prop refuse __proto__ paths |
| Malformed data from a peer or backend | ✅ wellFormed before anything is read; counted malformed, never logged or relayed | ⚠️ only with a validate-* wrapper: the invalid document is dropped (RC_PULL)70 |
| Clock-skew guard | ✅ stamps over sync.maxSkew (24 h) ahead refused (SKEW), never observed | — no peer clocks: conflicts compare document states |
Extensibility and operations
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Hooks and events | ||
| Write hooks / middleware | ✅ beforeWrite replaces data or refuses, afterWrite; one function per name | ✅ pre/post insert/save/remove, plus postCreate; many, series or parallel |
| Hooks on replicated changes | ✅ beforeApply skips or aborts per op, afterApply, beforeBootstrap | ⚠️ pull.modifier per replication; collection hooks skip pulled documents |
| Filter outgoing changes | ❌ none, by design: a withheld op is a gap; scopes decide what leaves | ✅ push.modifier transforms a document or drops it (null) |
| Read hooks | ⚠️ beforeRead / afterRead wrap get(); queries and snapshots read raw71 | ❌ none; postCreate runs when a document object is built |
| Refusal and error events | ✅ error with a code per refused peer op; conflict; host refused, hello | ✅ replication error$ (RC_PULL, RC_PUSH), conflict$; rejected local writes throw |
| Extensibility and operations | ||
| Plugin system | ❌ none; per-instance injection: fsi, validator, search, telemetry, logger, connect | ✅ addRxPlugin(): prototypes, overwritables and 24 plugin hooks72 |
| Typed errors | ✅ SyncomError.code: 30 codes, plus 3 on TypeErrors; full messages | ✅ RxError.code: 215 codes; the message text only with dev-mode |
| Logging | ✅ open({ logger }): a Console, a pino-shaped logger or false; named events | ⚠️ Premium logger storage wrapper (Pro Plus); the core logs nothing |
| OpenTelemetry | ✅ spans and metrics through an injected API; no OpenTelemetry dependency | ❌ none |
| Health and stats | ✅ stats(), peers(), hosted(), observable gauges | ❌ no stats API; replication states have active$ and awaitInSync() |
| Personal data in diagnostics | ✅ no id, origin, scope or key as a metric label; includeIdentity: false for spans | ⚠️ error messages embed their parameters: documents, pushed rows, password hashes73 |
| UI bindings | ||
| React | ❌ none | ✅ rxdb/plugins/react: RxDatabaseProvider, useRxQuery, useLiveRxQuery, useRxDocument… |
| Vue | ❌ none | ✅ rxdb/plugins/reactivity-vue: shallow refs through the $$ accessors |
| Angular | ❌ none | ✅ rxdb/plugins/reactivity-angular (signals); observables fit the async pipe |
| Svelte | ❌ none | ⚠️ no plugin; a Svelte 5 example app runs in CI |
| Signals and custom reactivity | ❌ none | ✅ reactivity option: Preact signals plugin, or your own factory; no Solid adapter |
| TanStack DB | ❌ none | ⚠️ @tanstack/rxdb-db-collection, published by TanStack (0.1.111) |
| Developer tooling | ||
| Development mode | — no separate mode: argument checks and full error messages always on | ✅ rxdb/plugins/dev-mode: readable errors, deep-freeze, schema and query checks74 |
| Test helpers | ⚠️ createMemoryFsi() for in-memory stores; contract suites stay in the repo | ✅ memory storage; rxdb/plugins/test-utils (fixtures, replication test suite); using closes databases75 |
| Own test suite and CI | 722 node:test tests; coverage gate 95/95/90/95; CI on Node 22 and 24, Linux | 74 test files per storage; Linux, Windows, browsers, Bun, Deno, 11 example apps; no coverage gate |
| Documentation | 21 pages, about 41,500 words, with an API reference | 271 pages, about 463,000 words: 98 articles, 46 landing pages |
| Example apps | ⚠️ two Node.js scripts (examples/main.js, domain.js), linted but not run | ✅ 13 example apps; 11 run in CI: Angular, React, React Native, Electron, Tauri, Vue, Svelte… |
Project and ecosystem
| Feature | syncom sync engine | RxDB local-first database |
|---|---|---|
| Releases and maintenance | ||
| Breaking major releases | none yet: 0.0.1, never published | 10 majors from 8.0 (2018) to 17.0 (2026); three in 202276 |
| Minor release cadence | no release yet; cut by hand from a checklist | about monthly: 17.0 to 17.6 between March and October 2026 |
| Free-tier changes over releases | — no paid tier (MIT) | ⚠️ Premium since 12.0 (2022); Worker storage moved in at 14.0; collection cap since 16.077 |
| Named production users | none yet (unreleased) | 10 testimonials (Readwise, Nutrien, Venice.ai, …); one case study |
| Support and ecosystem | ||
| Commercial support | ❌ none offered | ⚠️ paid: €180 one-hour consulting; Enterprise SLA, 24-hour first response78 |
| Community help | GitHub repository; no chat or forum | Discord, GitHub; a bug report needs a PR with a failing test |
| Third-party plugins | none (pre-release) | 8 listed, e.g. rxdb-hooks, rxdb-flexsearch, rxdb-supabase |
| Adoption and trust | ||
| Adoption signals | pre-release, not on npm yet | 23,412 GitHub stars; 63,815 npm downloads in the week to 2026-10-18 |
| Import from other databases | ❌ no importer and no JSON import; write records with insert() | ⚠️ migration guides only; importJSON() takes an RxDB dump of the same schema79 |
| Published benchmarks | ✅ nine libraries, six scenarios, memory and disk; machine and date stated | ⚠️ its own storages only; no machine, browser or date given80 |
| AI and coding agents | ||
| WebMCP agent tools | ❌ none | ✅ rxdb/plugins/webmcp: query, count, changes, insert, upsert, delete tools per collection; readOnly81 |
| Docs for coding agents | ✅ llms.txt on the docs site; CLAUDE.md in the repository | ✅ llms.txt plus three topic files; ERROR-MESSAGES.md, CLAUDE.md, AGENTS.md in the package82 |
Notes
- The agreement also requires shipping Premium code minified without source maps, gives no technical support, and says installing it (an access-token
postinstalldownloads and decrypts it) sends the package name, dependency list, git user name and email, and IP address to the vendor. - Measured: the 14th collection opens after a wait of about 1.86 s and the 15th throws
COL23. The count spans every database in the process; 17.0.0 lowered it from 16 to 13. - The docs say the dev-mode iframe is added on localhost only, but the
isLocalHost()check is commented out (dev-mode-tracking.ts:67), so it loads on any host unless the Premium flag is set. The production core makes no such call. - The Filesystem and SQLite storages are Premium (Pro); the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance.
- The page offers SQLite as 'a free solution' but its sample imports
rxdb-premium/plugins/storage-sqlite, and the sample'signoreDuplicate: truethrowsDB9without dev-mode (checked). - The article and examples/flutter import
rxdb/plugins/storage-lokijs, removed in 16.0.0; the Flutter CI job is commented out, and the Dart package on pub.dev is 15.0.0-beta.31 from 2023-11-21. - There are no cross-tab change events and no read-only open: the other tabs wait for the lock (
lock: 'wait') or route reads and writes to the owner through aBroadcastChannel. - The free Dexie storage prints a console warning advertising Premium once per process, when a database is created, unless the Premium package's
setPremiumFlag()is called; it also refuses indexes on fields that are not required (DXE1). - The trial throws
SQL3after 500 SQL calls per storage instance: a write runs one whole-table read plus one insert per new document, so the 251st serial insert fails (measured).SQL2stops it at 500 documents. It has no indexes or attachments. - One Node.js process per MongoDB database, per the docs; nothing in the storage enforces it (no change-stream listening, a TODO in the code).
- Attachments are not implemented (
getAttachmentData()throws "Method not implemented."); the DenoKV page does not say so. - The premium page still lists LokiJS among the free default storages.
- By default a write resolves before it reaches the persistent storage (
awaitWritePersistence: truewaits); no attachments; one JavaScript process per store, a SharedWorker for tabs. - Files are rewritten in place, with no temp file and rename: on node:fs a crash mid-write can tear a record file, which then fails to parse until
repair(id). - Two browser tabs are two instances: the second sees
LOCKED, so one tab or a worker must own the storage. repair(id)rebuilds a record while the log keeps a whole-record op for it; otherwise it isUNRECOVERABLE, and a peer's snapshot is the way back.- The chain is never compacted: one block file per local write and per applied batch, kept for good.
- Not supported by the SQLite trial (
SQL1), MongoDB, DenoKV or memory-mapped storages. - It deletes each migrated batch from the old storage and does not check write errors on the new one (a TODO in the code); deleted documents are dropped.
- Restoring an older copy of a node's directory is caught at the next hello (
REWOUND): the node moves past the seqs its host holds and bootstraps the lost state back; writes made between the restore and that hello are lost to the host. - A dump has no deleted documents and no revisions, holds encrypted fields in plain text, and is built in memory.
- Without dev-mode and a validator, an empty, over-long or missing key is accepted; on the memory storage an insert without the key was stored under
undefined(IndexedDB rejects that one). - Without dev-mode,
patch()overwrote afinalfield, and patching the primary key wrote a copy under the new key beside the original (checked by running); dev-mode refuses both (DOC9). - The
validate-ajv-standaloneplugin documented on rxdb.info is not in 17.6.0; it is on master, unreleased. - Without the wrapper, production accepts unknown top-level fields, wrong types and missing required fields (checked by running).
- Concurrent edits to two keys of one nested object keep only one side, and
update({ 'a.b': 1 })writes a top-level field nameda.b(both documented in the Storage and Sync guides). - 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.
- The document grows with every write: 200
$incleft 201 op rows, about 18 KB (checked by running), and every replica stores and transfers them. - Checked by running:
upsert()over a CRDT document reset its op list to one entry,incrementalUpsert()dropped the CRDT field, and with theupdateplugin added aftercrdt,update()wrote outside the ops. Dev-mode stops the last two, notupsert(). - Storage does not replicate through them, and
merge({ hits: 10 }, diff({ hits: 10 }, { hits: 3 }))is 13: a delta is not a plain patch. - Checked by running without dev-mode: editing
toJSON().nestedchanged the cached document (and the memory storage's copy), though the docs calltoJSON()immutable. - Nothing declares the relation, so an unrelated string that matches an id becomes a
Referencetoo (checked by running). - Load contracts only from a chain you control; the parser takes a single arrow function, and the result is written with
saveData(). - Without dev-mode, a
RegExpinstance is normalized to{}and the query matches every document (verified on 17.6.0). - Any
descsort key forces a re-sort of every match, since most storages have no descending index (query-planner.ts:44-48). - A range or an
order()on an indexed field is evaluated over every candidate: the whole collection unless an indexed equality or$innarrows it. Checked on 200 records:$gtread 200, with an indexed equality 20, a covered cursor 0. - Without dev-mode the memory storage fails at
addCollections()with an uncodedTypeError, and Dexie accepts the index on a required field but queries through it find nothing (checked by running). - RxServer compares the integer version only, with no schema hash. Elsewhere every replica must run the same migration, and the backend must accept checkpoints of older versions.
- The WebRTC page says there is "no master-slave concept"; the code makes one peer of every pair the master, and only the other side runs a replication.
- On the MongoDB and FoundationDB storages a process sees only its own writes (the change-stream relay is a commented-out TODO), so a live stream misses writes made through another process until its next
RESYNC. - Without
signalingServerUrlthe handler useswss://signaling.rxdb.info/, which the code warns is for demos only; the official quickstart app uses it. - The server ignores the client's
authmessage, and one message naming an unknown collection crashes the Node process (both checked by running). - In a one-off check over wrpc 2.0.1,
transport: 'sse'synced both ways with a live update; plainhttpcannot (the subscription is refused, then every call answers 428). - With
pull.batchSizeunset the query asks for 20 documents while the core stops at a page under 100, so a cycle pulls only 20 (checked by running). - The push compare runs inside
runTransactionwithout using the transaction, so a write landing between its read and commit is overwritten (code reading). - Some RxDB pages call the default last-write-wins; the code keeps the server's state whatever its time (https://rxdb.info/articles/zero-latency-local-first.html).
- Ops written before any node was registered, and compacted since, cannot reach a host; it is reported once per hello (
UNDELIVERABLE). - Over the WebSocket and WebRTC plugins attachment data is silently corrupted, since Blobs pass through
JSON.stringify(checked by running); RxServer, GraphQL, Appwrite and MongoDB drop it. - A visible tab that is not the leader also starts replicating (
toggleOnDocumentVisible, on by default), so two tabs can replicate at once (code reading). - The RSA key pair that unseals the records sits unprotected in
<path>/keys/, beside the data: whoever can read the directory can read the records. - crypto-js's passphrase mode derives key and IV with EVP_BytesToKey (MD5, one iteration) and authenticates nothing, and crypto-js is discontinued; the page calls the plugin 'tamper proof'.
- Mode 0600 where the backend has permissions; in OPFS or IndexedDB a plain entry. No passphrase, KMS or injected key.
- The stored hash lets anyone with the files test password guesses offline at SHA-256 speed (checked: it equals
sha256(JSON.stringify(password))). - One paragraph, with no example or test in the repository; a sealed field cannot be indexed, queried, counted or kept unique.
- The default
'verify'takes an unsigned op, so a relay can strip signatures; onlysync.signatures: 'require'refuses them. - Whoever can write the directory can rebuild it: a read checks a record against the block its file names, not that the block is on the chain, so a planted block passes and
validate()still answers true (checked). The ops in the log are what is signed. hellodoes not prove the peer holds its key, and the dialer'stokensits in plain text in<path>/remote/.- Defaults are open: with no
authHandlerevery request is admitted, and the Express adapter's default CORS reflects any request origin with credentials. The documentedvalidUntilis never read. The server listens on localhost unless told otherwise. Thereplication-websocketserver has no auth at all. - Once valid, a peer reads and writes every document of the collection.
- The peer names its scopes, and naming none means everything:
authorizemust checkhello.scopes. - A finer rule than scopes lives in
beforeApply: a skip is local (the op is still logged and relayed), a throw refuses the whole page. - Collection hooks do not run on replicated writes, and a 422 throws after the valid rows of the batch were written.
- Checked with three nodes: an op the middle node's validator or
beforeApplyskipped still reached the node behind it, because a withheld op would leave a gap in its origin's stream; every node re-checks it. A throwingbeforeApplyrefuses the whole page instead, and the sender offers it again later. - Over WebRTC there is no master: each peer's storage decides alone.
- The chain keeps an unsalted SHA-256 of every version this node wrote or applied, so whoever holds the directory can confirm a guessed record; until the next catalog save,
index/catalog.jsonmay still hold its indexed fields. - A removed document keeps all its data in the tombstone until then, and forever without the plugin;
cleanup(0)forces it once replications are in sync. - A peer that never comes back keeps what it had.
- Op compaction is not implemented, so a
crdtdocument carries every past value, in its tombstone too. actoris the writer's signed claim; a host holds it to whatauthorizeestablished inbeforeApply.- Without a wrapper a pulled document with wrong types and extra fields is stored as sent (checked by running).
- An
afterReadredaction does not reachselect()rows, so it is not a read access control. - Plugins are process-wide and cannot be removed.
- Checked by running: a wrong password's
DB1carries both the given and the stored password hash. - Without it an error carries only its code. With it, the storage must be wrapped in a schema validator (
DVM1), and in browsers it adds a tracking iframe (see Calls to the vendor). - The testing page's multi-tab simulation needs
ignoreDuplicate: true, which throwsDB9unless dev-mode is on, and the 13-collection limit counts every database a test run leaves open. - Majors 13, 14, 16 and 17 each told users of some or all storages to move their stored data with the
migration-storageplugin or to re-sync it from the backend. - The open-source cap on open collections was 16 in 16.0 and is 13 since 17.0. Two features went the other way:
migration-storage(15.0) and the Vue and Preact signals plugins (17.0) became free. - The Pro and Pro Plus agreement says 'No Technical Support'; free users get GitHub and Discord, and a bug report is expected as a pull request with a failing test.
- No converter ships: the Realm, PouchDB, Dexie, LokiJS, NeDB, GUN and other guides read the old store and call
bulkInsert/bulkUpsertby hand. - Most measured storages are Premium (OPFS, IndexedDB, SQLite, Filesystem); the free localStorage storage that most guides use is not measured.
- WebMCP is an experimental browser API; where
navigator.modelContextis missing the docs point to the@mcp-b/globalpolyfill. - In the 17.6.0 tarball all 215 entries of ERROR-MESSAGES.md are headed
## undefined, and COL23 states a limit of 16 collections where the code enforces 13.
Speed
Both run the same scenarios on the Benchmarks page, in a fresh process each, on one machine. RxDB runs in memory only there, because its Node.js disk storages are Premium.
Measured in memory, the two insert a record at about the same rate. RxDB updates a field about three times as fast and replicates a store into an empty replica about two and a half times as fast; syncom signs every op and verifies each one it receives (sync.signatures: false trades that away). syncom reads a record by id about three and a half times as fast, from its cache. RxDB's bulkInsert() is two orders of magnitude ahead, because syncom has no bulk write: each record is its own signed, logged op. On disk there is nothing to compare in Node.js, and a browser app that keeps records and syncs them ships about 43 KB of syncom against 90 KB of RxDB with Dexie, min+gzip.
Which to choose
- RxDB when the data already lives in a backend (Supabase, CouchDB, Firestore, MongoDB, GraphQL or your own REST) and the device keeps a replica of it; when the app needs React, Vue or Angular bindings, React Native, Expo, Capacitor or Electron, several tabs on one database, attachments, or a rich write API; and when a mature project with paid support matters more than the license of its storage layer.
- syncom when the nodes replicate among themselves, with no backend of record: servers, browser tabs and peers in a mesh, any of which can host. When concurrent edits must merge field by field and counters must add up, when you need to prove which node wrote what, verify what is stored and erase it everywhere, and when zero dependencies, a 42 KB bundle and an MIT license for everything, Node.js disk persistence included, are requirements.
- Neither for collaborative text: Yjs, Automerge or Loro merge text character by character, and can live in a field of either.