Skip to content

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, and purge() 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. final fields, 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 crdt plugin, merges instead. That plugin keeps every operation inside the document forever, orders concurrent ones by a random storage token, and upsert() 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 npm latest (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 Premium and 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.json keeps every cell with the file, line or page it was decided from. pnpm docs:comparison renders 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.

RxDBsyncom
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 documentthe record id, outside the data (getId, or set(id, data))
validate-ajv storage wrapperan entity schema (Standard Schema, a function, or validator.compile()), on by default
version + migrationStrategies, per collectionschema.version + schema.migrations, per storage: a lens on read, migrate() to rewrite
RxDocumenta 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 409swap(id, changes, prev), compared inside the write queue
$inc (merges only with the crdt plugin)inc(id, field, n), merges always
$addToSet / $pullAlladd() / remove(): an observed-remove set
remove() → _deleted, then the cleanup plugindelete() → 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, limitorder() / desc(), offset(), limit()
schema indexesentity 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 hooksbeforeWrite(ctx)
postInsert / postSave / postRemove hooksafterWrite(ctx, op)
replicateRxCollection({ pull, push })storage.sync.addNode({ url }) over an injected link
RxServer, or your own endpointsstorage.sync.unit(), mounted on a wrpc server by any node
conflictHandlernone: fixed per-field rules; beforeApply can skip a peer's op
pull.modifier / push.modifierbeforeApply (a verdict, no rewrite) / none: scopes decide what leaves
RxServer authHandler, queryModifier, changeValidatorunit({ authorize }), scopes, the entity validators and beforeApply
schema encrypted: ['field'] + password{ encrypted: true } per record: sealed for this node, never synced
multiInstance + leader-electionone 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 ​

Featuresyncom
sync engine
RxDB
local-first database
Package and license
Version compared0.0.1 (@alexify/syncom)17.6.0 (rxdb), npm latest since 2026-10-05
Release statuspre-release, not on npm yetstable; on npm since 2016, 652 versions
License of the coreMIT, the whole packageApache-2.0
License of the server partMIT: 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— nonePremium: 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 vendornone: 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 only24 direct plus the required peer rxjs: 74 packages installed
Language and module formatJavaScript, CommonJS, no build step; src/ ships as isTypeScript, built by Babel into CommonJS and ESM; sideEffects: false
Entry pointsroot plus 4 subpaths: /wrpc, /idb, /search, /contracts54 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 store41.9 KB min+gzip: the browser entry, the whole engine80.4 KB min+gzip: createRxDatabase with Dexie; 44.9 KB with localStorage
Browser bundle with sync42.6 KB min+gzip with the wrpc link adapter90.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 requirementssecure context, OPFS, Web Crypto with Ed25519 and RSA-OAEPcrypto.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 ​

Featuresyncom
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 loadsthe catalog (every id, indexed values) and the retained log, into memorylazy 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 changethree per local write: log batch, chain block, record (two with chain: false)17one 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 ​

Featuresyncom
sync engine
RxDB
local-first database
Documents and collections
Data modelJSON 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 overwritethe later stamp (ts, origin); a node's clock never falls behind what it sawcrdt: 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 metadatameta: 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 ​

Featuresyncom
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 returnsplain 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 livein memory (the catalog), saved to index/catalog.json, caught up from the log at openin 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 ​

Featuresyncom
sync engine
RxDB
local-first database
Model and protocol
Replication modeloperation log; version vectors per origin and scope; pages of opsdocument state: pull by checkpoint, push with assumedMasterState (fork and master)
What crosses the wireops as positional packOps arrays, signed; a set carries only its deltawhole 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 uphello, then one push per 500 ops, then one changes subscriptionpulls 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 clienta cursor per peer, the version vector, the op log within retentionper replication: two checkpoints and the last master state of every document
Topology
Topologymesh: any node dials and hosts; hubs dial each othera 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 roomone link per pair you add (peer.join(room) + addNode); ops relay any pathfull mesh: one replication per pair, N(N−1)/2 in all, no cap
STUN / TURN serversyour iceServers; wrpc sets nonesimple-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 resolutionbuilt in: per-field LWW by (ts, origin), per-origin counter sums, OR-setsconflictHandler.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 settledon every node, by the same deterministic ruleson 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 trackingversion vectors for delivery; per-field stamps; no causal context per recordnone across replicas: a compare-and-set against the master's last state
Role of peer clocksdecide concurrent register writes; a clock never falls behind what it sawunused 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 sizespageSize 500 ops per push and per page; 500 records per snapshot pagepull.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 serveshello, 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 storagea full syncom node: its own log and records over its fsiany RxStorage of the server's RxDatabase: memory, MongoDB, FoundationDB…

Security and integrity ​

Featuresyncom
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 encryptedthe record's whole data; its id, collection and timestamps stay readablethe 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 replicationencrypted records stay local: no op, no snapshot, no peer copyencrypted 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 ​

Featuresyncom
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 CI722 node:test tests; coverage gate 95/95/90/95; CI on Node 22 and 24, Linux74 test files per storage; Linux, Windows, browsers, Bun, Deno, 11 example apps; no coverage gate
Documentation21 pages, about 41,500 words, with an API reference271 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 ​

Featuresyncom
sync engine
RxDB
local-first database
Releases and maintenance
Breaking major releasesnone yet: 0.0.1, never published10 majors from 8.0 (2018) to 17.0 (2026); three in 202276
Minor release cadenceno release yet; cut by hand from a checklistabout 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 usersnone 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 helpGitHub repository; no chat or forumDiscord, GitHub; a bug report needs a PR with a failing test
Third-party pluginsnone (pre-release)8 listed, e.g. rxdb-hooks, rxdb-flexsearch, rxdb-supabase
Adoption and trust
Adoption signalspre-release, not on npm yet23,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 ​

  1. The agreement also requires shipping Premium code minified without source maps, gives no technical support, and says installing it (an access-token postinstall downloads and decrypts it) sends the package name, dependency list, git user name and email, and IP address to the vendor.
  2. 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.
  3. 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.
  4. The Filesystem and SQLite storages are Premium (Pro); the free SQLite trial stops at 500 documents and after 500 SQL calls per storage instance.
  5. The page offers SQLite as 'a free solution' but its sample imports rxdb-premium/plugins/storage-sqlite, and the sample's ignoreDuplicate: true throws DB9 without dev-mode (checked).
  6. 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.
  7. 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 a BroadcastChannel.
  8. 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).
  9. The trial throws SQL3 after 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). SQL2 stops it at 500 documents. It has no indexes or attachments.
  10. 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).
  11. Attachments are not implemented (getAttachmentData() throws "Method not implemented."); the DenoKV page does not say so.
  12. The premium page still lists LokiJS among the free default storages.
  13. By default a write resolves before it reaches the persistent storage (awaitWritePersistence: true waits); no attachments; one JavaScript process per store, a SharedWorker for tabs.
  14. 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).
  15. Two browser tabs are two instances: the second sees LOCKED, so one tab or a worker must own the storage.
  16. repair(id) rebuilds a record while the log keeps a whole-record op for it; otherwise it is UNRECOVERABLE, and a peer's snapshot is the way back.
  17. The chain is never compacted: one block file per local write and per applied batch, kept for good.
  18. Not supported by the SQLite trial (SQL1), MongoDB, DenoKV or memory-mapped storages.
  19. 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.
  20. 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.
  21. A dump has no deleted documents and no revisions, holds encrypted fields in plain text, and is built in memory.
  22. 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).
  23. Without dev-mode, patch() overwrote a final field, and patching the primary key wrote a copy under the new key beside the original (checked by running); dev-mode refuses both (DOC9).
  24. The validate-ajv-standalone plugin documented on rxdb.info is not in 17.6.0; it is on master, unreleased.
  25. Without the wrapper, production accepts unknown top-level fields, wrong types and missing required fields (checked by running).
  26. Concurrent edits to two keys of one nested object keep only one side, and update({ 'a.b': 1 }) writes a top-level field named a.b (both documented in the Storage and Sync guides).
  27. 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.
  28. The document grows with every write: 200 $inc left 201 op rows, about 18 KB (checked by running), and every replica stores and transfers them.
  29. Checked by running: upsert() over a CRDT document reset its op list to one entry, incrementalUpsert() dropped the CRDT field, and with the update plugin added after crdt, update() wrote outside the ops. Dev-mode stops the last two, not upsert().
  30. Storage does not replicate through them, and merge({ hits: 10 }, diff({ hits: 10 }, { hits: 3 })) is 13: a delta is not a plain patch.
  31. Checked by running without dev-mode: editing toJSON().nested changed the cached document (and the memory storage's copy), though the docs call toJSON() immutable.
  32. Nothing declares the relation, so an unrelated string that matches an id becomes a Reference too (checked by running).
  33. Load contracts only from a chain you control; the parser takes a single arrow function, and the result is written with saveData().
  34. Without dev-mode, a RegExp instance is normalized to {} and the query matches every document (verified on 17.6.0).
  35. Any desc sort key forces a re-sort of every match, since most storages have no descending index (query-planner.ts:44-48).
  36. A range or an order() on an indexed field is evaluated over every candidate: the whole collection unless an indexed equality or $in narrows it. Checked on 200 records: $gt read 200, with an indexed equality 20, a covered cursor 0.
  37. Without dev-mode the memory storage fails at addCollections() with an uncoded TypeError, and Dexie accepts the index on a required field but queries through it find nothing (checked by running).
  38. 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.
  39. 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.
  40. 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.
  41. Without signalingServerUrl the handler uses wss://signaling.rxdb.info/, which the code warns is for demos only; the official quickstart app uses it.
  42. The server ignores the client's auth message, and one message naming an unknown collection crashes the Node process (both checked by running).
  43. In a one-off check over wrpc 2.0.1, transport: 'sse' synced both ways with a live update; plain http cannot (the subscription is refused, then every call answers 428).
  44. With pull.batchSize unset the query asks for 20 documents while the core stops at a page under 100, so a cycle pulls only 20 (checked by running).
  45. The push compare runs inside runTransaction without using the transaction, so a write landing between its read and commit is overwritten (code reading).
  46. 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).
  47. Ops written before any node was registered, and compacted since, cannot reach a host; it is reported once per hello (UNDELIVERABLE).
  48. 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.
  49. A visible tab that is not the leader also starts replicating (toggleOnDocumentVisible, on by default), so two tabs can replicate at once (code reading).
  50. 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.
  51. 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'.
  52. Mode 0600 where the backend has permissions; in OPFS or IndexedDB a plain entry. No passphrase, KMS or injected key.
  53. The stored hash lets anyone with the files test password guesses offline at SHA-256 speed (checked: it equals sha256(JSON.stringify(password))).
  54. One paragraph, with no example or test in the repository; a sealed field cannot be indexed, queried, counted or kept unique.
  55. The default 'verify' takes an unsigned op, so a relay can strip signatures; only sync.signatures: 'require' refuses them.
  56. 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.
  57. hello does not prove the peer holds its key, and the dialer's token sits in plain text in <path>/remote/.
  58. Defaults are open: with no authHandler every request is admitted, and the Express adapter's default CORS reflects any request origin with credentials. The documented validUntil is never read. The server listens on localhost unless told otherwise. The replication-websocket server has no auth at all.
  59. Once valid, a peer reads and writes every document of the collection.
  60. The peer names its scopes, and naming none means everything: authorize must check hello.scopes.
  61. 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.
  62. Collection hooks do not run on replicated writes, and a 422 throws after the valid rows of the batch were written.
  63. Checked with three nodes: an op the middle node's validator or beforeApply skipped 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 throwing beforeApply refuses the whole page instead, and the sender offers it again later.
  64. Over WebRTC there is no master: each peer's storage decides alone.
  65. 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.json may still hold its indexed fields.
  66. 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.
  67. A peer that never comes back keeps what it had.
  68. Op compaction is not implemented, so a crdt document carries every past value, in its tombstone too.
  69. actor is the writer's signed claim; a host holds it to what authorize established in beforeApply.
  70. Without a wrapper a pulled document with wrong types and extra fields is stored as sent (checked by running).
  71. An afterRead redaction does not reach select() rows, so it is not a read access control.
  72. Plugins are process-wide and cannot be removed.
  73. Checked by running: a wrong password's DB1 carries both the given and the stored password hash.
  74. 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).
  75. The testing page's multi-tab simulation needs ignoreDuplicate: true, which throws DB9 unless dev-mode is on, and the 13-collection limit counts every database a test run leaves open.
  76. Majors 13, 14, 16 and 17 each told users of some or all storages to move their stored data with the migration-storage plugin or to re-sync it from the backend.
  77. 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.
  78. 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.
  79. No converter ships: the Realm, PouchDB, Dexie, LokiJS, NeDB, GUN and other guides read the old store and call bulkInsert/bulkUpsert by hand.
  80. Most measured storages are Premium (OPFS, IndexedDB, SQLite, Filesystem); the free localStorage storage that most guides use is not measured.
  81. WebMCP is an experimental browser API; where navigator.modelContext is missing the docs point to the @mcp-b/global polyfill.
  82. 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.

Insert a record, in memory
Add one record to a store of 1,000. Nothing touches the disk. ops/s, log scale, higher is better.
syncomchain: false, createMemoryFsi() · v0.0.1
17.5k
RxDBmemory storage · v17.6.0
16.7k
syncomdefault, createMemoryFsi() · v0.0.1
16.2k
Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 1 s samples (5 rounds for a batch or a replication), each library in a fresh process · 2026-10-22
Bundle size
What a browser app ships to keep records on the device and sync them (esbuild, minified, tree-shaken, the network client left out), min+gzip, WebAssembly included. Lower is better.
syncomopen() over OPFS + the wrpc link adapter
42.6 KB
RxDBrxdb + Dexie storage + replication
90.1 KB
esbuild 0.28.2 · 2026-10-23

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.

Released under the MIT License.