A real-time whiteboard on my own CRDT engine, with zero lost edits under network faults
A collaborative whiteboard built on a CRDT engine I wrote, benchmarked honestly against Yjs and Automerge
Summary
- Problem
- Collaborative editing has to converge without a central sequencer, survive dropped connections, and never lose an acknowledged edit. Owning the engine means owning its correctness.
- My role
- Sole developer
- Team
- Personal project
- Status
- In progress
- Timeline
- October 2026
- Stack
- TypeScript, Node.js (ws), React, PostgreSQL, Redis, IndexedDB, Docker Compose
- Next
- Write up the architecture, limits and security posture in full, which the repository marks as the next phase.
What shipped
- Held edit-to-remote p95 at 47 ms for 50 editors, against a 300 ms target, and under target to about 150 editors, by fanning edits out through Redis.
- Lost zero acknowledged edits across latency, bandwidth and partition faults by acknowledging only after Postgres commits and replaying from state vectors.
- Built a dependency-free CRDT engine, measured 7.6× slower and 28× larger than Yjs on text, by implementing RGA and last-writer-wins registers from scratch.
- Edit-to-remote p95, 50 editors
- 47 ms
- Baseline: 300 ms design target
- Concurrent edits measured until applied on every other replica, 2 server nodes sharing Redis and Postgres on one Apple M5 (results/scale.json); p95 stays under 300 ms to about 150 editors and breaks between 150 and 200
- Lost ops under network faults
- 0
- Clients edit through toxiproxy with +200 ms latency, 10 kbit/s bandwidth and a connection cut mid-edit, then heal; all four phases converged (results/chaos.json)
- Text authoring against Yjs
- 7.6× slower
- Baseline: Yjs 1,050 ms; Tandem 7,577 ms
- Trace replay on Apple M5 with Node 22 (results/bench.json); Tandem's encoded text is also 28× larger than Yjs's
On this page

Source: results/scale.json in the Tandem repository
Context and constraints
A whiteboard where several people edit at once has to converge without a central sequencer, keep working offline, and never lose an edit the server has acknowledged. The goal was to own and defend the engine itself, its data types, delta sync, garbage collection and binary encoding, rather than wrap an existing library, and to benchmark it honestly against the libraries it replaces.
Architecture
The CRDT core in packages/crdt has no runtime dependencies: RGA for ordered collections such as z-order and text runs, last-writer-wins registers for properties, and hybrid logical clocks. The React client applies every edit to its CRDT and IndexedDB first, then sends binary operations over WebSocket. Server nodes share edits through Redis fan-out, persist an op log in PostgreSQL, and acknowledge only after the commit. Reconnecting clients replay whatever the server’s state vector says it is missing.
Request flows
- An edit reaches every replica: React client (CRDT + IndexedDB) → WebSocket server nodes → PostgreSQL op log
- Fan out across server nodes: React client (CRDT + IndexedDB) → WebSocket server nodes → Redis fan-out → WebSocket server nodes
Engineering decisions
Build on Yjs, or write the engine?
- Options
- Build on Yjs
- Build on Automerge
- Write my own CRDT core and benchmark it against both
- Chose
- My own CRDT core with zero runtime dependencies, with Yjs and Automerge as the baseline in every benchmark
- Trade-off
- It loses Yjs's maturity, and it is slower and larger on text.
- Evidence
- On the text trace Tandem took 7,577 ms to author against Yjs's 1,050 ms; on whiteboard merges it beat Automerge, 161 ms against 334 ms.
Which sequence CRDT orders shapes and text?
- Options
- Fugue
- RGA
- Chose
- RGA for v1, reversing an earlier decision for Fugue
- Trade-off
- Concurrently typed runs at the same position can interleave, the known RGA weave.
- Evidence
- RGA's rule is small enough to re-derive in an independent reference implementation and property-test exhaustively; a subtly wrong Fugue would break convergence, the core safety property.
When does the server acknowledge an edit?
- Options
- On receipt
- After broadcasting
- Only after the rows commit to Postgres
- Chose
- Only after the rows commit to Postgres
- Trade-off
- Every acknowledgement waits for a database commit.
- Evidence
- A CI test SIGKILLs the server mid-run and restarts it; the reconnecting client still sees every acknowledged op, and the test fails if the ordering is reversed.
How does an offline client catch up?
- Options
- Reload the whole board
- Replay everything since a timestamp
- Replay what the server is missing from its state vector
- Chose
- Replay only what the server's state vector says it is missing
- Trade-off
- Every replica keeps a state vector and tombstones.
- Evidence
- Edits land in the CRDT and IndexedDB before the network, so nothing is lost while offline; the partition phase of the chaos test converged with zero lost ops.
Evaluation
Every benchmark includes Yjs and Automerge as baselines, measured on an Apple M5 with 16 GB and Node 22.
| Engine | Text author | Text merge | Text bytes | Whiteboard author | Whiteboard merge |
|---|---|---|---|---|---|
| Tandem | 7,577 ms | 332 ms | 8,673,080 | 307 ms | 161 ms |
| Yjs | 1,050 ms | 28 ms | 311,038 | 218 ms | 94 ms |
| Automerge | 13,464 ms | 2,631 ms | 129,198 | 125,070 ms | 334 ms |
Tandem loses to Yjs on text authoring speed (7.6×) and encoded size (28×). Scale testing with 2 server nodes found edit-to-remote p95 of 47 ms at 50 editors and 185.8 ms at 150, rising to 571 ms at 200. The raw results are in results/bench.json, results/scale.json and results/chaos.json in the Tandem repository.
Deployment and operations
PostgreSQL, Redis and toxiproxy run under Docker Compose, and the server falls back to in-memory stores without Postgres. Boards are shared through signed links as editor or viewer. CI runs formatting, lint, type checks, the tests and the build, including the crash-recovery test that kills a real server process mid-run.
Results
What’s done: the engine, the protocol, the server and client, the benchmarks, scale and chaos testing, and crash recovery. The design target of p95 under 300 ms at 50 editors is met with margin and holds to about 150 editors per board. All four chaos phases converged with zero lost ops. The engine is slower and larger than Yjs on text, which is the price of owning it.
Code highlight
Inserting into the RGA, even before the parent arrives
An op whose parent hasn't arrived is held until it does, so delivery order doesn't matter, and a duplicate insert is a no-op apart from its newer timestamp.
insert(id: RGAId, parent: RGAId | null, value: V, hlc?: HLC): boolean {
const key = rgaIdKey(id);
const existing = this.nodes.get(key);
if (existing !== undefined) {
if (
hlc !== undefined &&
(existing.flagHlc === null || hlcCompare(hlc, existing.flagHlc) > 0)
) {
existing.flagHlc = hlc;
existing.deleted = false;
}
return false;
}
const parentKey = parent === null ? null : rgaIdKey(parent);
if (parentKey !== null && !this.nodes.has(parentKey)) {
const list = this.pending.get(parentKey) ?? [];
list.push({ id, parent, value, ...(hlc !== undefined ? { hlc } : {}) });
this.pending.set(parentKey, list);
return true;
}
this.place(id, parentKey, value, hlc);
return true;
}What I'd do next
Finish the full write-up of the architecture, limits and security posture, then revisit Fugue for text once a reference implementation can verify it.
Talk about this project
I'm happy to walk through any decision here in more depth.