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, 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.title = '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. The browser entry carries a budget
that fails the build when exceeded.
| Entry | raw | min | min+gzip | budget |
|---|---|---|---|---|
| main entry — browser (@alexify/syncom) | 296.0 KB | 122.3 KB | 41.9 KB | 42.0 KB |
| main entry — node (@alexify/syncom) | 279.9 KB | 121.3 KB | 41.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 |
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, counters and sets, encryption at rest (AES-256-GCM under an RSA-wrapped key), purge() for the right to be forgotten |
| 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 |
| Convergence | Last-writer-wins registers, per-origin counters, observed-remove sets, tombstones with revival — any order, any number of times, same result |
| 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, 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 and log rolls, plus outcome and lag metrics — nested under wrpc's spans, which carry the trace across the wire. 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, Jazz), the local-first databases (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 features, each cell decided from the published source. In short: syncom is a database that merges field by field, not a text CRDT. 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 text or list CRDT and no UI bindings, 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.
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. 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 |
|---|---|---|---|---|---|---|
| syncom default, createMemoryFsi() · v0.0.1 |
16,200 | 17,100 | 972,000 | 16,700 | 16,000 | 25,200 |
| syncom chain: false, createMemoryFsi() · v0.0.1 |
17,500 | 18,800 | 929,000 | 18,600 | 17,200 | 26,200 |
| Yjs Y.Doc, a Y.Map per record · v13.6.33 |
235,000 | 382,000 | 3,680,000 | — | 275,000 | 99,700 |
| Automerge Automerge.change, Counter · v3.5.0 |
512 | 1,990 | 10,600,000 | 1,950 | 8,960 | 20,900 |
| Loro LoroDoc, a LoroMap per record · v1.16.4 |
31,300 | 412,000 | 313,000 | 349,000 | 83,100 | 105,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,520 | 1,340 | 958,000 | 1,480 | 1,690 | 5,830 |
| syncom chain: false, node:fs · v0.0.1 |
2,060 | 3,080 | 951,000 | 3,060 | 2,740 | 5,820 |
| 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-22. 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 | 124.1 KB | 42.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 and decrypt 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, a field written
as two kinds at once, encrypted records staying local, no proof of key possession at hello — are listed under
Guarantees and limits.
- Why syncom? · Compared with alternatives · Benchmarks · Architecture
- Getting started
- Storage · CRDT deltas · Blockchain log · Smart contracts
- Sync · wrpc transport · WebRTC · Schema and validation · Queries · Backends · Scaling · Browser
- 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; the browser budget 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:dev # the VitePress siteSee CHANGELOG.md.
See SECURITY.md. Report vulnerabilities privately — do not open a public issue.
MIT © Alexis Technologies