SDE
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
Sole developer
The 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.
Team: Personal project. Timeline: October 2026.
What changed
- 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
Architecture
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
Decision 1 of 4
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.
Decision 2 of 4
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.
Decision 3 of 4
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.
Decision 4 of 4
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.
Inserting into the RGA, even before the parent arrives
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;
}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.