Skip to content

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.

Insert a record, on disk
Add one record to a store of 1,000. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: RxDB (Node.js disk storages are Premium).
PouchDBLevelDB (Node default) · v9.0.0
26.4k
Loroupdates appended to a file · v1.16.4
19.5k
Yjsy-leveldb · v13.6.33
19.2k
syncomchain: false, node:fs · v0.0.1
2,059
syncomdefault, node:fs · v0.0.1
1,523
OrbitDBdocuments, LevelDB · v4.0.0
952
Automergeautomerge-repo, NodeFS storage · v3.5.0
490
Fireprooffile:// store · v0.24.19
300
TinyBaseMergeableStore, file persister · v10.0.1
172
GUNRAD on node:fs · v0.2020.1241
3.63
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

In memory

LibraryInsert 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,20017,100972,00016,70016,00025,200
syncom
chain: false, createMemoryFsi() · v0.0.1
17,50018,800929,00018,60017,20026,200
Yjs
Y.Doc, a Y.Map per record · v13.6.33
235,000382,0003,680,000—275,00099,700
Automerge
Automerge.change, Counter · v3.5.0
5121,99010,600,0001,9508,96020,900
Loro
LoroDoc, a LoroMap per record · v1.16.4
31,300412,000313,000349,00083,100105,000
TinyBase
MergeableStore · v10.0.1
22,900108,0001,460,000—23,70011,500
PouchDB
pouchdb-adapter-memory · v9.0.0
20,3008,31030,100—74,00017,800
RxDB
memory storage · v17.6.0
16,70050,400281,000—2,220,00064,100
Fireproof
memory:// store · v0.24.19
310377726—22,300—
OrbitDB
documents, memory blockstore · v4.0.0
1,08031.181.4—1,090220

On disk

LibraryInsert 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,5201,340958,0001,4801,6905,830
syncom
chain: false, node:fs · v0.0.1
2,0603,080951,0003,0602,7405,820
Yjs
y-leveldb · v13.6.33
19,20022,4003,690,000—155,00053,700
Automerge
automerge-repo, NodeFS storage · v3.5.0
4901,25010,500,0001,2007,8203,100
Loro
updates appended to a file · v1.16.4
19,50043,300285,00042,60066,900108,000
TinyBase
MergeableStore, file persister · v10.0.1
1723041,390,000—15,40011,100
PouchDB
LevelDB (Node default) · v9.0.0
26,4008,81042,000—65,50016,500
Fireproof
file:// store · v0.24.19
300335720—20,700—
GUN
RAD on node:fs · v0.2020.1241
3.633.77749—failed—
OrbitDB
documents, LevelDB · v4.0.0
95217.482.4—921193

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. The chain: false rows 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 verifyOp row of syncom's own benchmarks). sync.signatures: false skips 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 LoroCounter is 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's file:// store, GUN's RAD and OrbitDB's LevelDB. Nobody calls fsync: syncom's durable is off by default, and so is LevelDB's sync. 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, PouchDB PouchDB.replicate, and RxDB replicateRxCollection with 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, which pnpm bench:compare:install installs, 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.

Cold start
Load the library, open a store of 1,000 records on disk, read one: a fresh process, median of 10 runs. Lower is better.
syncomchain: false, node:fs · v0.0.1
36 ms
syncomdefault, node:fs · v0.0.1
37 ms
TinyBaseMergeableStore, file persister · v10.0.1
51 ms
Loroupdates appended to a file · v1.16.4
52 ms
Yjsy-leveldb · v13.6.33
83 ms
GUNRAD on node:fs · v0.2020.1241
85 ms
PouchDBLevelDB (Node default) · v9.0.0
85 ms
Automergeautomerge-repo, NodeFS storage · v3.5.0
183 ms
Fireprooffile:// store · v0.24.19
240 ms
OrbitDBdocuments, LevelDB · v4.0.0
577 ms
Apple M3 Max · 16 cores · Node v24.14.1
LibraryLoad and openFirst readTotal
syncom
chain: false, node:fs · v0.0.1
36.0 ms0.3 ms36.3 ms
syncom
default, node:fs · v0.0.1
36.0 ms0.9 ms37.0 ms
TinyBase
MergeableStore, file persister · v10.0.1
50.7 ms0.1 ms50.8 ms
Loro
updates appended to a file · v1.16.4
51.2 ms0.7 ms51.9 ms
Yjs
y-leveldb · v13.6.33
82.7 ms0.1 ms82.7 ms
GUN
RAD on node:fs · v0.2020.1241
56.5 ms28.0 ms84.6 ms
PouchDB
LevelDB (Node default) · v9.0.0
81.6 ms3.2 ms85.1 ms
Automerge
automerge-repo, NodeFS storage · v3.5.0
182.7 ms0.0 ms182.7 ms
Fireproof
file:// store · v0.24.19
158.2 ms81.8 ms239.6 ms
OrbitDB
documents, LevelDB · v4.0.0
531.9 ms45.1 ms577.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.

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.
GUNgun (localStorage; its WebSocket mesh is built in)
17.1 KB
TinyBaseMergeableStore + IndexedDB persister + custom synchronizer
23.8 KB
Yjsyjs + y-indexeddb + y-protocols/sync
24.5 KB
PouchDBpouchdb-browser
40.4 KB
syncomopen() over OPFS + the wrpc link adapter
42.6 KB
RxDBrxdb + Dexie storage + replication
90.1 KB
Fireproof@fireproof/core
204 KB
OrbitDB@orbitdb/core + helia + libp2p, IndexedDB blockstore
354 KB
Loroloro-crdt (no persistence of its own)
1115 KB
Automergeautomerge-repo + IndexedDB storage
1172 KB
esbuild 0.28.2 · 2026-10-23
LibraryWhat is bundledminmin+gzip
GUN
gun@0.2020.1241
gun (localStorage; its WebSocket mesh is built in)42.2 KB17.1 KB
TinyBase
tinybase@10.0.1
MergeableStore + IndexedDB persister + custom synchronizer56.4 KB23.8 KB
Yjs
yjs@13.6.33, y-indexeddb@9.0.12, y-protocols@1.0.7
yjs + y-indexeddb + y-protocols/sync80.2 KB24.5 KB
PouchDB
pouchdb-browser@9.0.0
pouchdb-browser117.8 KB40.4 KBpouchdb-browser carries its HTTP adapter (the CouchDB client) with it
syncom
@alexify/syncom@0.0.1
open() over OPFS + the wrpc link adapter124.1 KB42.6 KBstorage, 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 + replication282.3 KB90.1 KB
Fireproof
@fireproof/core@0.24.19
@fireproof/core784.2 KB204.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 blockstore1185.6 KB353.7 KBcreateHelia 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 storage3744.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: syncom's own benchmarks
The public API end to end on node:fs: a write is an op appended to the log, a record file and a block. ops/s, log scale, higher is better.
insertsmall record
1,156
getcached
162k
getcold (re-verifies the block)
218
updateone field
1,855
setwhole record
1,855
swapmatch (writes)
1,927
swapmismatch (reads only)
167k
hasexisting id
185k
recordcached Record instance
8.9M
list2000 ids (catalog)
11.3M
hasRecord2000 ids (catalog)
13.5M
selectcovered eq over 2000
21.4k
selecteq + order + limit over 2000
11k
selectuncovered filter over 2000
980
opencatalog caught up from the log (2000 records)
31
opencatalog rebuilt from a scan (2000 records, integrity lazy)
30
pnpm bench --save · Apple M3 Max · 16 cores · Node v24.14.1 · 1 s per row · 2026-10-22
Storage — 16 rows
Benchmarkops/sper operation
insert — small record1,156865 µs
get — cached161,7916.18 µs
get — cold (re-verifies the block)2184.59 ms
update — one field1,855539 µs
set — whole record1,855539 µs
swap — match (writes)1,927519 µs
swap — mismatch (reads only)166,9975.99 µs
has — existing id184,7195.41 µs
record — cached Record instance8,901,096112 ns
list — 2000 ids (catalog)11,325,13088.3 ns
hasRecord — 2000 ids (catalog)13,477,59474.2 ns
select — covered eq over 200021,36246.8 µs
select — eq + order + limit over 200011,00090.9 µs
select — uncovered filter over 20009801.02 ms
open — catalog caught up from the log (2000 records)3132.3 ms
open — catalog rebuilt from a scan (2000 records, integrity lazy)3033.3 ms
Search — 9 rows
Benchmarkops/sper operation
index.put — a 43-word record16,12862 µs
index.query — one word over 20001,733577 µs
index.query — two words over 20001,442693 µs
index.query — a typo over 20007,824128 µs
insert — 2 search fields (43 words)1,561641 µs
select — $text one word over 2000, limit 201,348742 µs
select — $text + eq narrowing over 20001,129886 µs
count — $text over 20001,174852 µs
open — catalog reloaded with text (2000 records)10100 ms
Apply — 21 rows
Benchmarkops/sper operation
applyOp — set one field on a 5-field entry4,717,320212 ns
applyOp — inc a counter4,421,459226 ns
applyOp — add to a 20-element set236,3994.23 µs
applyOp — remove from a 20-element set246,1874.06 µs
applyOp — write the whole record930,4561.07 µs
applyOp — merge a peer entry667,3511.5 µs
packOps — 500 writes71,66714 µs
unpackOps — 500 writes40,29424.8 µs
applyRemote — 100 writes, new records6,549153 µs
applyRemote — 500 writes, new records6,997143 µs
applyRemote — 100 signed writes, new records5,154194 µs
verifyOp — 100 signed writes36,65927.3 µs
applyRemote — 100 sets on cached records10,62194.2 µs
applyRemote — 100 incs on cached records10,07799.2 µs
applyRemote — 100 adds on cached records6,702149 µs
applyRemote — 100 writes, new records (chain: false)7,175139 µs
applyRemote — 500 writes, new records (chain: false)7,173139 µs
applyRemote — 100 signed writes, new records (chain: false)5,015199 µs
applyRemote — 100 sets on cached records (chain: false)10,91091.7 µs
applyRemote — 100 incs on cached records (chain: false)9,994100 µs
applyRemote — 100 adds on cached records (chain: false)6,736148 µs
Sync — 6 rows
Benchmarkops/sper operation
write on A, applied on B (one hop)5391.86 ms
write on A, applied on C (two hops)6791.47 ms
update on A, applied on C (two hops)7411.35 ms
snapshot — one page of 500 out of 2000 (host side)8,920112 µs
bootstrap — a new node joins a host of 2000 records2500 ms
append of another scope's op while a scoped feed listens1,711584 µs
Op log — 7 rows
Benchmarkops/sper operation
append — one op5,605178 µs
append — batch of 100312,4843.2 µs
since — last 500 entries629,7951.59 µs
since — everything (no cursor)1,708585 µs
since — 20 from the middle, scoped3,511,359285 ns
append with a roll every 8 batches4,931203 µs
reopen — index + segments + tail1190.9 ms
Chain — 7 rows
Benchmarkops/sper operation
addBlock — small payload5,107196 µs
addBlock — 4 KB payload4,283233 µs
readBlock — single block13,11776.2 µs
validate — last 1 block11,28788.6 µs
validate — last 10 blocks1,098911 µs
validate — last 100 blocks1128.93 ms
validate — full 200-block chain5717.5 ms
Hash — 5 rows
Benchmarkops/sper operation
calculateHash — empty genesis block1,166,378857 ns
calculateHash — small record (5 x 16 B)1,040,941961 ns
calculateHash — medium record (50 x 64 B)138,0387.24 µs
calculateHash — large record (200 x 256 B)14,57068.6 µs
calculateHash — deeply nested record633,8151.58 µs
CRDT — 9 rows
Benchmarkops/sper operation
diff — 20 flat keys, all changed324,8323.08 µs
diff — 20 flat keys, one changed444,5782.25 µs
diff — depth 3, width 435,56328.1 µs
diff — array as a grow-only set2,830,451353 ns
merge — 20 flat keys2,130,062469 ns
merge — depth 3, width 44,959,915202 ns
merge — numeric counter increment5,398,362185 ns
apply — 100 inc records207,9464.81 µs
apply — 100 delta records8,824113 µs
Emitter — 6 rows
Benchmarkops/sper operation
emit awaited — 0 listeners14,423,60669.3 ns
emit awaited — 1 sync listener3,747,799267 ns
emit awaited — 1 listener, (data, delta)3,781,384264 ns
emit awaited — 1 async listener3,402,202294 ns
emit awaited — 2 sync listeners3,221,211310 ns
emit fire-and-forget — 1 sync listener4,688,543213 ns

pnpm bench --save · Apple M3 Max · 16 cores · Node v24.14.1 · 1 s per row · 2026-10-22.

Reproducing the numbers ​

bash
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 README

pnpm 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.

Released under the MIT License.