Benchmarks
Every number on this page comes from scripts in the repository. The results are recorded in bench/results/, and pnpm bench:report turns them into these charts and tables. They were measured on one machine, named under each chart. Read them as relative guidance: the order and the orders of magnitude carry over to other hardware, the exact figures do not. What each library can do, feature by feature, is on the comparison page.
Compared with other libraries
Nine libraries keep the same collection of small records (the to-do of every local-first demo: a title, a flag, a priority, an owner, a counter) and are asked to do what an application does with it. They insert a record into a store of 1,000, update a field, read a record, increment a counter that converges, insert 1,000 at once, and replicate the whole store to an empty replica. Each scenario runs twice: in memory, where nothing touches the disk, and on disk, where every library uses its own persistence on node:fs and every write is awaited. Each library is used the way its documentation shows; the adapters are in bench/compare/adapters/. Pick a scenario and a storage; hover over or focus a bar to see the time of one operation, the spread of the samples and the exact version.
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).
How to read it
The libraries do different work for one call, so read the charts as a price list, not a ranking.
- A syncom write does more. A Yjs or Loro write changes a document in memory and, on disk, appends one update. A syncom write is an op, signed with the node's Ed25519 key, appended to the op log, the record's own file, its catalog row and, unless
chain: false, a block in the chain. That is four file writes per write with the chain and two without, and it is what lets a record be checked, purged, repaired, replicated by scope, queried and verified by every peer. Thechain: falserows show what the chain costs: a quarter to a half of a write on disk, under a tenth in memory. - Signatures are the price in memory. Every write is signed and every op a peer sends is verified against its origin, and in memory that is most of what a write costs: syncom does about 16,000 inserts a second there, where it did about 37,500 before ops were signed. A peer's page is verified on Node's thread pool, four checks at a time by default — about 36,000 ops a second (the
verifyOprow of syncom's own benchmarks).sync.signatures: falseskips both, for a mesh that trusts its transport. - Writes on disk are where syncom pays. It does about 1,500 inserts a second (2,060 without the chain), where the LevelDB-backed Yjs and PouchDB and Loro's single append do 19,000 to 26,000: what is left is the file writes themselves. A write does not cost more as the store grows: the catalog is saved every sixteenth of its size rather than whole every 64 writes. Further down the disk chart: TinyBase's file persister rewrites the whole store on every save (about 170 a second), Automerge's change cost grows with the document, and GUN waits for RAD's 250 ms flush before it acknowledges a write (about 4 a second).
- Reads come from a cache. syncom answers 930,000 to 970,000 reads a second on both lines, each a JSON copy of the record. Automerge (10.6 million) and Yjs (3.7 million) read their materialized documents, and TinyBase reads 1.4 million. PouchDB (30,000 to 42,000), Fireproof (about 700) and OrbitDB (about 80) go to their storage; OrbitDB walks its log for every read.
- Replication is bound by verification. An empty syncom replica pulls 1,000 records at 25,000 to 26,000 a second in memory, through the same path a WebSocket peer takes (hello, push, a feed of pages, JSON across the boundary), checking every op's signature on the way in, on Node's thread pool (before ops were signed, the same pull ran at 108,000 to 127,000 a second). Loro manages 105,000, Yjs 100,000 and RxDB 64,000; Automerge (21,000), PouchDB (18,000) and TinyBase (11,500) come behind, and OrbitDB, over loopback TCP, 220. On disk the replica also writes every record's file and one chain block per page, and manages about 5,800 a second: behind Loro (108,000, one append), Yjs (54,000), PouchDB (16,500) and TinyBase (11,100), ahead of Automerge (3,100) and OrbitDB (193).
- Batches. syncom has no batch write: 1,000 inserts are 1,000 ops, each appended on its own (16,000 to 17,200 a second in memory, 1,690 to 2,740 on disk), each signed. RxDB's in-memory
bulkInsert(2.2 million) and a Yjs transaction (155,000 to 275,000) write the batch at once. GUN has no number: in two attempts, a batch of 1,000 concurrent puts left a write without its acknowledgement for 30 s. - Counters. Loro's
LoroCounteris the fastest (349,000 a second in memory, 43,000 on disk). syncom does 16,700 to 18,600 in memory and 1,480 to 3,060 on disk, and Automerge about 2,000. The others have no counter that converges, so they are not in this chart. - Cold start. syncom opens a store of 1,000 records and reads one in 36 to 37 ms, the fastest here: its catalog is one JSON file, and nothing compiles WebAssembly or starts a network stack. Loro and TinyBase take about 51 ms, Yjs and PouchDB about 83 to 85 ms (LevelDB), Automerge 183 ms (WebAssembly), Fireproof 240 ms and OrbitDB 577 ms (libp2p and Helia).
Method
- A fresh process for every pair. Every (library, scenario) pair runs in its own process and its own temporary directory, so no library's JIT feedback, heap or open handles leak into the next measurement.
- Checked before timed. The store is seeded with 1,000 records first. Then the library must give the scenario's answers: a record reads back as it was written, an update shows, three increments make 3, a batch's last record reads back, a replica reads every one of the 1,000 records. A mismatch aborts the run, because a call that does the wrong thing is fast and measures nothing.
- One write in flight. An operation that returns a promise is awaited before the next one, as an application that awaits its writes does. Operations are timed in chunks, so reading the clock does not dominate a call of a few hundred nanoseconds. After a one-second warmup, five one-second samples are taken and the median is reported. A sample stops after 5,000 operations, so a fast library does not grow its store a hundred times larger than a slow one's. A batch or a replication is a round, timed five times after one warmup round.
- On disk means each library's own persistence, with its defaults: syncom on
node:fs, y-leveldb, automerge-repo's NodeFS adapter, Loro's updates appended to a file (Loro ships no storage), TinyBase's file persister, PouchDB's LevelDB, Fireproof'sfile://store, GUN's RAD and OrbitDB's LevelDB. Nobody callsfsync: syncom'sdurableis off by default, and so is LevelDB'ssync. RxDB is in memory only, because its Node.js disk storages are Premium. - Sync means each library's own protocol, in process. syncom runs over the in-process link its tests use (JSON across the boundary, like a wrpc client). Yjs exchanges state vectors as y-protocols does, Automerge runs its sync protocol (two repositories on a MessageChannel, on disk), Loro exports from the replica's version, TinyBase uses two
LocalSynchronizers, PouchDBPouchDB.replicate, and RxDBreplicateRxCollectionwith a pull handler. OrbitDB has no in-process transport, so its two nodes talk libp2p over TCP on 127.0.0.1. Fireproof syncs only through a cloud or a gateway, and GUN only over its WebSocket or WebRTC mesh, so neither takes part in that scenario. - The libraries are installed on their own. They live in
bench/compare/package.json, whichpnpm bench:compare:installinstalls, so none of them is a dependency of syncom.
Cold start
Load the library, open a store of 1,000 records on disk, read one, in a fresh process: what a command-line tool, a serverless function or a page that reloads pays once.
| Library | Load and open | First read | Total |
|---|---|---|---|
| syncom chain: false, node:fs · v0.0.1 | 36.0 ms | 0.3 ms | 36.3 ms |
| syncom default, node:fs · v0.0.1 | 36.0 ms | 0.9 ms | 37.0 ms |
| TinyBase MergeableStore, file persister · v10.0.1 | 50.7 ms | 0.1 ms | 50.8 ms |
| Loro updates appended to a file · v1.16.4 | 51.2 ms | 0.7 ms | 51.9 ms |
| Yjs y-leveldb · v13.6.33 | 82.7 ms | 0.1 ms | 82.7 ms |
| GUN RAD on node:fs · v0.2020.1241 | 56.5 ms | 28.0 ms | 84.6 ms |
| PouchDB LevelDB (Node default) · v9.0.0 | 81.6 ms | 3.2 ms | 85.1 ms |
| Automerge automerge-repo, NodeFS storage · v3.5.0 | 182.7 ms | 0.0 ms | 182.7 ms |
| Fireproof file:// store · v0.24.19 | 158.2 ms | 81.8 ms | 239.6 ms |
| OrbitDB documents, LevelDB · v4.0.0 | 531.9 ms | 45.1 ms | 577.4 ms |
Bundle size
What a browser app ships to keep records on the device and sync them: the store, its browser persistence and its sync protocol, bundled with esbuild for the browser (minified, tree-shaken). The network client is left out where the library allows it: syncom's wrpc client, Yjs' y-websocket, TinyBase's WebSocket synchronizer. A WebAssembly module the library loads is counted with the JavaScript, gzipped too.
| 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 |
syncom's own benchmarks
pnpm bench runs one script per subject in bench/: the storage API end to end, a peer's page as it is applied, replication over the in-process link, full-text search, the op log, the chain, hashing, the CRDT helpers and the emitter. They are the numbers to watch for a regression, and pnpm bench --save records them.
Storage — 16 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| insert — small record | 1,156 | 865 µs |
| get — cached | 161,791 | 6.18 µs |
| get — cold (re-verifies the block) | 218 | 4.59 ms |
| update — one field | 1,855 | 539 µs |
| set — whole record | 1,855 | 539 µs |
| swap — match (writes) | 1,927 | 519 µs |
| swap — mismatch (reads only) | 166,997 | 5.99 µs |
| has — existing id | 184,719 | 5.41 µs |
| record — cached Record instance | 8,901,096 | 112 ns |
| list — 2000 ids (catalog) | 11,325,130 | 88.3 ns |
| hasRecord — 2000 ids (catalog) | 13,477,594 | 74.2 ns |
| select — covered eq over 2000 | 21,362 | 46.8 µs |
| select — eq + order + limit over 2000 | 11,000 | 90.9 µs |
| select — uncovered filter over 2000 | 980 | 1.02 ms |
| open — catalog caught up from the log (2000 records) | 31 | 32.3 ms |
| open — catalog rebuilt from a scan (2000 records, integrity lazy) | 30 | 33.3 ms |
Search — 9 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| index.put — a 43-word record | 16,128 | 62 µs |
| index.query — one word over 2000 | 1,733 | 577 µs |
| index.query — two words over 2000 | 1,442 | 693 µs |
| index.query — a typo over 2000 | 7,824 | 128 µs |
| insert — 2 search fields (43 words) | 1,561 | 641 µs |
| select — $text one word over 2000, limit 20 | 1,348 | 742 µs |
| select — $text + eq narrowing over 2000 | 1,129 | 886 µs |
| count — $text over 2000 | 1,174 | 852 µs |
| open — catalog reloaded with text (2000 records) | 10 | 100 ms |
Apply — 21 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| applyOp — set one field on a 5-field entry | 4,717,320 | 212 ns |
| applyOp — inc a counter | 4,421,459 | 226 ns |
| applyOp — add to a 20-element set | 236,399 | 4.23 µs |
| applyOp — remove from a 20-element set | 246,187 | 4.06 µs |
| applyOp — write the whole record | 930,456 | 1.07 µs |
| applyOp — merge a peer entry | 667,351 | 1.5 µs |
| packOps — 500 writes | 71,667 | 14 µs |
| unpackOps — 500 writes | 40,294 | 24.8 µs |
| applyRemote — 100 writes, new records | 6,549 | 153 µs |
| applyRemote — 500 writes, new records | 6,997 | 143 µs |
| applyRemote — 100 signed writes, new records | 5,154 | 194 µs |
| verifyOp — 100 signed writes | 36,659 | 27.3 µs |
| applyRemote — 100 sets on cached records | 10,621 | 94.2 µs |
| applyRemote — 100 incs on cached records | 10,077 | 99.2 µs |
| applyRemote — 100 adds on cached records | 6,702 | 149 µs |
| applyRemote — 100 writes, new records (chain: false) | 7,175 | 139 µs |
| applyRemote — 500 writes, new records (chain: false) | 7,173 | 139 µs |
| applyRemote — 100 signed writes, new records (chain: false) | 5,015 | 199 µs |
| applyRemote — 100 sets on cached records (chain: false) | 10,910 | 91.7 µs |
| applyRemote — 100 incs on cached records (chain: false) | 9,994 | 100 µs |
| applyRemote — 100 adds on cached records (chain: false) | 6,736 | 148 µs |
Sync — 6 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| write on A, applied on B (one hop) | 539 | 1.86 ms |
| write on A, applied on C (two hops) | 679 | 1.47 ms |
| update on A, applied on C (two hops) | 741 | 1.35 ms |
| snapshot — one page of 500 out of 2000 (host side) | 8,920 | 112 µs |
| bootstrap — a new node joins a host of 2000 records | 2 | 500 ms |
| append of another scope's op while a scoped feed listens | 1,711 | 584 µs |
Op log — 7 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| append — one op | 5,605 | 178 µs |
| append — batch of 100 | 312,484 | 3.2 µs |
| since — last 500 entries | 629,795 | 1.59 µs |
| since — everything (no cursor) | 1,708 | 585 µs |
| since — 20 from the middle, scoped | 3,511,359 | 285 ns |
| append with a roll every 8 batches | 4,931 | 203 µs |
| reopen — index + segments + tail | 11 | 90.9 ms |
Chain — 7 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| addBlock — small payload | 5,107 | 196 µs |
| addBlock — 4 KB payload | 4,283 | 233 µs |
| readBlock — single block | 13,117 | 76.2 µs |
| validate — last 1 block | 11,287 | 88.6 µs |
| validate — last 10 blocks | 1,098 | 911 µs |
| validate — last 100 blocks | 112 | 8.93 ms |
| validate — full 200-block chain | 57 | 17.5 ms |
Hash — 5 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| calculateHash — empty genesis block | 1,166,378 | 857 ns |
| calculateHash — small record (5 x 16 B) | 1,040,941 | 961 ns |
| calculateHash — medium record (50 x 64 B) | 138,038 | 7.24 µs |
| calculateHash — large record (200 x 256 B) | 14,570 | 68.6 µs |
| calculateHash — deeply nested record | 633,815 | 1.58 µs |
CRDT — 9 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| diff — 20 flat keys, all changed | 324,832 | 3.08 µs |
| diff — 20 flat keys, one changed | 444,578 | 2.25 µs |
| diff — depth 3, width 4 | 35,563 | 28.1 µs |
| diff — array as a grow-only set | 2,830,451 | 353 ns |
| merge — 20 flat keys | 2,130,062 | 469 ns |
| merge — depth 3, width 4 | 4,959,915 | 202 ns |
| merge — numeric counter increment | 5,398,362 | 185 ns |
| apply — 100 inc records | 207,946 | 4.81 µs |
| apply — 100 delta records | 8,824 | 113 µs |
Emitter — 6 rows
| Benchmark | ops/s | per operation |
|---|---|---|
| emit awaited — 0 listeners | 14,423,606 | 69.3 ns |
| emit awaited — 1 sync listener | 3,747,799 | 267 ns |
| emit awaited — 1 listener, (data, delta) | 3,781,384 | 264 ns |
| emit awaited — 1 async listener | 3,402,202 | 294 ns |
| emit awaited — 2 sync listeners | 3,221,211 | 310 ns |
| emit fire-and-forget — 1 sync listener | 4,688,543 | 213 ns |
pnpm bench --save · Apple M3 Max · 16 cores · Node v24.14.1 · 1 s per row · 2026-10-22.
Reproducing the numbers
pnpm bench:compare:install # the libraries, into bench/compare/node_modules
pnpm bench:compare # every library and scenario, the cold start → bench/results/compare.json
pnpm bench:size # bundle sizes → bench/results/size.json
pnpm bench --save # syncom's own benchmarks → bench/results/engine.json
pnpm bench:report # the charts (docs/public/benchmarks) and the tables here and in the READMEpnpm bench:compare --only syncom,yjs --scenarios sync measures a subset and merges it into the existing results. --samples, --sample-ms, --warmup-ms, --max-ops, --rounds and --warmup-rounds change the method, and --cold-runs 0 skips the cold start.