Fast, zero-dependency distributed data sync engine π
syncom is a local-first data engine for JavaScript. It stores records on
disk β or in the browser's origin-private filesystem β commits every write to
a hash-linked log so tampering is detectable, and replicates every write to
peers as an operation that converges without coordination: last-writer-wins
registers, per-origin counters, observed-remove sets, lists and text that merge by
position, trees whose moves never form a cycle, tombstones. The transport is injected β
wrpc out of the box β and the package has no
runtime dependencies.
'use strict';
const { open } = require('@alexify/syncom');
const main = async () => {
const storage = await open({ path: 'data' });
const id = await storage.insert({ name: 'Ada' });
const record = await storage.record(id);
record.on('update', (data, delta) => console.log('changed', delta));
record.name = 'Ada Lovelace'; // changed { name: 'Ada Lovelace' }
await record.save();
await storage.inc(id, 'hits', 1); // a counter: increments add up across nodes
console.log(await storage.validate({ last: 10 })); // true
};
main();π Documentation Β· Getting started Β· API reference
- Installation
- Bundle size
- Quick Start
- Features
- Compared with alternatives
- Benchmarks
- Browser usage
- Status
- Documentation
- Contributing
- Changelog
- Security
- License
Requires Node.js β₯ 22, or a modern browser with OPFS and Web Crypto.
npm install @alexify/syncomMeasured by pnpm size on every CI run. Every browser entry carries a budget
that fails the build when exceeded; a subpath is off the main entry, so an app
pays for what it imports.
| Entry | raw | min | min+gzip | budget |
|---|---|---|---|---|
| main entry β browser (@alexify/syncom) | 456.0 KB | 193.2 KB | 66.0 KB | 66.0 KB |
| main entry β node (@alexify/syncom) | 432.8 KB | 192.2 KB | 65.6 KB | β |
| wrpc adapter β browser (@alexify/syncom/wrpc) | 3.4 KB | 1.4 KB | 0.7 KB | 2.0 KB |
| contracts β browser (@alexify/syncom/contracts) | 3.7 KB | 1.6 KB | 0.8 KB | 1.0 KB |
| idb backend β browser (@alexify/syncom/idb) | 3.7 KB | 1.7 KB | 0.8 KB | 2.0 KB |
| search β browser (@alexify/syncom/search) | 4.2 KB | 1.7 KB | 0.9 KB | 2.0 KB |
| order β browser (@alexify/syncom/order) | 4.9 KB | 2.3 KB | 1.0 KB | 1.5 KB |
| undo β browser (@alexify/syncom/undo) | 36.1 KB | 16.3 KB | 6.1 KB | 6.5 KB |
| branch β browser (@alexify/syncom/branch) | 14.8 KB | 7.4 KB | 3.2 KB | 3.5 KB |
const { open, diff, merge } = require('@alexify/syncom');
// A schema turns entity names into collections.
const storage = await open({
path: 'data',
schema: { entities: { Post: {}, Author: {} } },
});
const author = await storage.Author.insert({ name: 'Ada' });
const post = await storage.Post.insert({ title: 'Draft', author: author.id });
// Records expose live properties and track their own delta.
post.title = 'Published';
console.log(post.delta()); // { title: 'Published' }
await post.save();
// Compare-and-swap, for writers that must not clobber each other.
await storage.swap(post.id, { title: 'Final' }, { title: 'Published' });
// The CRDT helpers work standalone, on plain objects. Numbers ADD, which is
// what lets two peers each increment a counter and converge.
merge({ hits: 10 }, { hits: 1 }); // { hits: 11 }
diff({ tags: ['a'] }, { tags: ['a', 'b'] }); // { tags: ['b'] }Host one end on a wrpc server, dial it from the other. wrpc is yours to install β syncom only takes the client you built:
// the hub
const { Server, defineRouter } = require('@alexify/wrpc');
const router = defineRouter({ syncom: storage.sync.unit({ access: 'public' }) });
await new Server({ router, host: '0.0.0.0', port: 8000, protocol: 'http' }).listen();
// a node β a server, a CLI, a browser tab
const { WrpcClient } = require('@alexify/wrpc');
const { createWrpcLink } = require('@alexify/syncom/wrpc');
const storage = await open({
path: 'data',
sync: { connect: async (node) => createWrpcLink(await WrpcClient.connect(node.url)) },
});
await storage.sync.addNode({ url: 'ws://hub.example.com:8000/api' });Every node can be both, and ops relay hop by hop, so any connected graph of nodes converges.
| Area | What you get |
|---|---|
| Storage | Records with live properties and per-record deltas, schema-driven collections, compare-and-swap writes, encryption at rest (AES-256-GCM under an RSA-wrapped key), purge() for the right to be forgotten, repair(id) from the log |
| Field kinds | A field is a register, a counter (inc()), a set (add()/remove()), a list or a text (splice(), push(), updateText(); setAt()/move() on a list), or a tree (treeMove()/treeRemove()). A text carries marks (mark(), unmark(), unmarkById(), spans()), and cursor()/index() name a place that survives edits; @alexify/syncom/order mints fractional indexes for ordering records. See Lists, text and trees |
| Schema | A metaschema model, any Standard Schema (zod, valibot, arktype), ajv or a compiled JSON Schema validates every write before it is stamped β locally and again on the hub. Versions ride on ops; an older record reads through a lens (migrations, then the validator's aliases and defaults); migrate() rewrites it. See Schema and validation |
| Queries | select({ orgId, status: { $in } }).order('name').limit(20).fetch() over an in-memory record catalog: indexed queries with no I/O, unique keys, watch() for live views. See Queries |
| Full-text search | select({ $text: 'quick fox' }): fuzzy, typo-tolerant, TF-IDF ranked trigram search over an entity's search fields, best first, narrowed by any equality β the index is @alexify/syncom/search (under 1 KB), injected at open({ search }). See Full-text search |
| Backends | node:fs, OPFS, IndexedDB (@alexify/syncom/idb) or memory β five functions injected at open({ fsi }); durable: true for fsync, one instance per directory. See Backends |
| Replication | Every write is an op in a persisted log; peers exchange ops over an injected link, resume from where they stopped, and bootstrap from a streamed snapshot when they cannot; scopes for partial replication |
| Versions | history(id) and get(id, { at }) by event id, stamp or vector, folded from the log; tags of the whole storage (tag(), at(name)); diff(), restore(), applyPatches() and compact(id), every way back new ops that merge; an undo stack (@alexify/syncom/undo) and branches that merge back (@alexify/syncom/branch). See Versions and history |
| Convergence | Last-writer-wins registers, per-origin counters, observed-remove sets, lists and text that merge by position (Fugue β each writer's run whole), trees whose concurrent moves never form a cycle, tombstones with revival β any order, any number of times, same result. A field two nodes make as different kinds at once ends as the same kind everywhere, by a fixed precedence; what a peer can make a node hold is bounded. See Sync |
| Transport | @alexify/syncom/wrpc adapts a WrpcClient β over a WebSocket, or a WebRTC PeerLink's remote from @alexify/wrpc/webrtc; storage.sync.unit() is the router unit a wrpc server, or a browser's PeerHost, mounts. Reconnects, resumable subscriptions, binary streams and rooms come from wrpc. See WebRTC |
| Integrity | Every write appends a hash-linked block (chain: false turns it off for a hub); reads re-verify the record against the block that committed it and throw on a mismatch; repair(id) rebuilds a record from the log |
| Signed ops | Every op is signed with the node's Ed25519 key, and the node id is that key: a peer verifies an op against its origin through any relay, a purge leaves a header that still verifies, sync.signatures: 'require' refuses unsigned ops. See Signatures |
| CRDT | diff / merge / apply over plain objects β nested maps merge recursively, arrays are grow-only sets, numbers add so counters converge |
| Contracts | @alexify/syncom/contracts: store a procedure in the chain by hash, load it anywhere, run it against a read-only view of storage; failures are recorded as blocks. A subpath, so the package root never runs a block's code |
| Portability | One API over node:fs + node:crypto on the server and OPFS + Web Crypto in the browser, chosen by the browser export condition |
| Hooks and events | open({ hooks }) runs your code inside a write, a read or a peer's batch β validate, stamp, redact, skip β and rejects with a typed HookError when it throws; on() events (change, commit, apply, peer, sync, β¦) observe everything and can never break it. See Hooks and events |
| Observability | Injected OpenTelemetry (open({ telemetry })): spans for local writes, applied batches, pushes, bootstraps, log rolls, tags, history reads and patches, plus outcome and lag metrics β nested under wrpc's spans, which carry the trace across the wire. stats() reports the queue, the cache, the log, the tags and the files that lag the log. See OpenTelemetry |
| Scaling | Hubs that dial each other, scopes as buckets, per-peer push limits, a reserved shared-log contract for the broker-backed hub. See Scaling |
| Dependencies | None. wrpc, metaschema and fake-indexeddb are devDependencies for the integration tests; everything is injected at runtime |
syncom sits where the CRDT libraries (Yjs, Automerge, Loro), the local-first databases (Jazz, RxDB, PouchDB, TinyBase, Dexie Cloud, Fireproof), the peer-to-peer and event-sourced stores (GUN, OrbitDB, Evolu, LiveStore) and the sync services (ElectricSQL, PowerSync, Zero, Triplit, InstantDB) meet. The comparison puts all nineteen in one table of forty-eight features, each cell decided from the published source. In short: syncom is a database that merges field by field β and whose list, text and tree fields merge by position, with marks, cursors and moves, like a text CRDT's β with tags, history, undo and branches on top. It syncs with no service to run, its hosts validate and scope what peers send, and its log is verifiable and purgeable. It has no editor bindings and no UI bindings yet, and it is a pre-release, not on npm yet. RxDB, the closest of them, is compared feature by feature in 300 rows, from its code and every page of its docs, and Yjs, Automerge and Loro as CRDT libraries in 141 rows, from their code.
Measured against Yjs, Automerge, Loro, TinyBase, PouchDB, RxDB, Fireproof, GUN and OrbitDB on the same scenarios: insert, update, read, a converging counter, a batch, replicating a store, in memory and on disk, and typing into a text, in memory. Each library runs in a fresh process, is used the way its documentation shows, and is checked to give the right answer before it is timed. The method and the interactive charts are on the Benchmarks page.
In memory
| Library | Insert a record ops/s |
Update a field ops/s |
Read a record ops/s |
Increment a counter ops/s |
Insert 1,000 at once records/s |
Replicate a store records/s |
Type at the end of a text ops/s |
Type at the start of a text ops/s |
Type anywhere in a text ops/s |
|---|---|---|---|---|---|---|---|---|---|
| syncom default, createMemoryFsi() Β· v0.0.1 |
15,200 | 17,200 | 845,000 | 17,100 | 15,800 | 25,100 | 1,800 | 388 | 537 |
| syncom chain: false, createMemoryFsi() Β· v0.0.1 |
16,700 | 19,400 | 885,000 | 18,800 | 16,900 | 25,800 | 1,820 | 391 | 550 |
| Yjs Y.Doc, a Y.Map per record Β· v13.6.33 |
235,000 | 382,000 | 3,680,000 | β | 275,000 | 99,700 | 346,000 | 525,000 | 30,400 |
| Automerge Automerge.change, Counter Β· v3.5.0 |
512 | 1,990 | 10,600,000 | 1,950 | 8,960 | 20,900 | 23,700 | 21,600 | 20,600 |
| Loro LoroDoc, a LoroMap per record Β· v1.16.4 |
31,300 | 412,000 | 313,000 | 349,000 | 83,100 | 105,000 | 619,000 | 444,000 | 311,000 |
| TinyBase MergeableStore Β· v10.0.1 |
22,900 | 108,000 | 1,460,000 | β | 23,700 | 11,500 | β | β | β |
| PouchDB pouchdb-adapter-memory Β· v9.0.0 |
20,300 | 8,310 | 30,100 | β | 74,000 | 17,800 | β | β | β |
| RxDB memory storage Β· v17.6.0 |
16,700 | 50,400 | 281,000 | β | 2,220,000 | 64,100 | β | β | β |
| Fireproof memory:// store Β· v0.24.19 |
310 | 377 | 726 | β | 22,300 | β | β | β | β |
| OrbitDB documents, memory blockstore Β· v4.0.0 |
1,080 | 31.1 | 81.4 | β | 1,090 | 220 | β | β | β |
On disk
| Library | Insert a record ops/s |
Update a field ops/s |
Read a record ops/s |
Increment a counter ops/s |
Insert 1,000 at once records/s |
Replicate a store records/s |
|---|---|---|---|---|---|---|
| syncom default, node:fs Β· v0.0.1 |
1,320 | 1,530 | 891,000 | 1,210 | 1,620 | 5,310 |
| syncom chain: false, node:fs Β· v0.0.1 |
2,420 | 2,840 | 875,000 | 2,960 | 2,640 | 5,910 |
| Yjs y-leveldb Β· v13.6.33 |
19,200 | 22,400 | 3,690,000 | β | 155,000 | 53,700 |
| Automerge automerge-repo, NodeFS storage Β· v3.5.0 |
490 | 1,250 | 10,500,000 | 1,200 | 7,820 | 3,100 |
| Loro updates appended to a file Β· v1.16.4 |
19,500 | 43,300 | 285,000 | 42,600 | 66,900 | 108,000 |
| TinyBase MergeableStore, file persister Β· v10.0.1 |
172 | 304 | 1,390,000 | β | 15,400 | 11,100 |
| PouchDB LevelDB (Node default) Β· v9.0.0 |
26,400 | 8,810 | 42,000 | β | 65,500 | 16,500 |
| Fireproof file:// store Β· v0.24.19 |
300 | 335 | 720 | β | 20,700 | β |
| GUN RAD on node:fs Β· v0.2020.1241 |
3.63 | 3.77 | 749 | β | failed | β |
| OrbitDB documents, LevelDB Β· v4.0.0 |
952 | 17.4 | 82.4 | β | 921 | 193 |
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-24. Medians, rounded to three significant figures; β where the library has no such operation, no such storage, or no in-process sync, failed where it did not finish twice (the chart above says why).
| Library | What is bundled | min | min+gzip | |
|---|---|---|---|---|
| GUN gun@0.2020.1241 |
gun (localStorage; its WebSocket mesh is built in) | 42.2 KB | 17.1 KB | |
| TinyBase tinybase@10.0.1 |
MergeableStore + IndexedDB persister + custom synchronizer | 56.4 KB | 23.8 KB | |
| Yjs yjs@13.6.33, y-indexeddb@9.0.12, y-protocols@1.0.7 |
yjs + y-indexeddb + y-protocols/sync | 80.2 KB | 24.5 KB | |
| PouchDB pouchdb-browser@9.0.0 |
pouchdb-browser | 117.8 KB | 40.4 KB | pouchdb-browser carries its HTTP adapter (the CouchDB client) with it |
| syncom @alexify/syncom@0.0.1 |
open() over OPFS + the wrpc link adapter | 195.0 KB | 66.6 KB | storage, schema, queries, the op log and the sync protocol are one entry |
| RxDB rxdb@17.6.0, dexie@4.4.6, rxjs@7.8.2 |
rxdb + Dexie storage + replication | 282.3 KB | 90.1 KB | |
| Fireproof @fireproof/core@0.24.19 |
@fireproof/core | 784.2 KB | 204.0 KB | |
| OrbitDB @orbitdb/core@4.0.0, helia@6.1.4, libp2p@3.5.1, @libp2p/gossipsub@15.0.23, blockstore-idb@4.0.1 |
@orbitdb/core + helia + libp2p, IndexedDB blockstore | 1185.6 KB | 353.7 KB | createHelia brings Helia's default libp2p configuration with it |
| Loro loro-crdt@1.16.4 |
loro-crdt (no persistence of its own) | 3405.0 KB WebAssembly 3319.1 KB |
1114.8 KB | |
| Automerge @automerge/automerge@3.5.0, @automerge/automerge-repo@2.6.0-alpha.3, @automerge/automerge-repo-storage-indexeddb@2.6.0-alpha.3 |
automerge-repo + IndexedDB storage | 3744.5 KB WebAssembly 3558.8 KB |
1171.9 KB |
The browser export condition selects browser.js automatically β no bundler
configuration needed.
import { open, diff, merge } from '@alexify/syncom';Crypto is asynchronous there (calculateHash, encrypt, decrypt, sign and
verify return promises), and storage lives in the origin-private filesystem β or in
IndexedDB through open({ fsi: createIdbFsi() }) from @alexify/syncom/idb. browser.d.ts
declares those signatures separately. A browser node dials the mesh through
@alexify/syncom/wrpc like any other, and hosts for its peers over WebRTC.
See Browser.
0.0.1 β a pre-release, not published to npm, and there is no release until the package does what it is meant
to; the API may still change. Everything on this page works today and is covered by the test suite, including
replication over real wrpc WebSockets. Known limits β a counter straddling a concurrent reset, encrypted records
staying local, no proof of key possession at hello β are on the
Sync page.
- Why syncom? Β· Compared with alternatives Β· Compared with RxDB Β· Compared with CRDT libraries Β· Benchmarks Β· Architecture
- Getting started
- Storage Β· Schema and validation Β· Queries Β· Lists, text and trees Β· Versions and history Β· Hooks and events Β· CRDT deltas Β· Blockchain log Β· Smart contracts
- Sync Β· wrpc transport Β· WebRTC Β· Scaling
- Browser Β· Backends Β· OpenTelemetry
- API reference
See CONTRIBUTING.md.
pnpm install
pnpm test # node --test, recursive over tests/
pnpm test:coverage # c8 over src/
pnpm test:types # tsd against the .d.ts files
pnpm lint # oxlint
pnpm format # oxfmt (format:check in CI)
pnpm size # bundle-size report; a browser budget exceeded fails the run
pnpm bench # every script in bench/ (--save writes bench/results/engine.json)
pnpm bench:compare:install && pnpm bench:compare # against the alternatives
pnpm bench:report # the charts and the tables of the docs and this README
pnpm docs:comparison # the comparison matrix from docs/comparison.json
pnpm docs:dev # the VitePress siteSee CHANGELOG.md.
See SECURITY.md. Report vulnerabilities privately β do not open a public issue.
MIT Β© Alexis Technologies