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

Architecture: TandemReact client (CRDT + IndexedDB), Redis fan-out, WebSocket server nodes, CRDT core (RGA + LWW), PostgreSQL op log. Connections: React client (CRDT + IndexedDB) to WebSocket server nodes; WebSocket server nodes to CRDT core (RGA + LWW); WebSocket server nodes to PostgreSQL op log; WebSocket server nodes to Redis fan-out; Redis fan-out to WebSocket server nodes.binary opscommit before ackpublishto other nodesReact client (CRDT + IndexedDB)clientRedis fan-outqueueWebSocket server nodesgatewayCRDT core (RGA + LWW)servicePostgreSQL op logdata store

Request flows

  1. An edit reaches every replica: React client (CRDT + IndexedDB) → WebSocket server nodes → PostgreSQL op log
  2. Fan out across server nodes: React client (CRDT + IndexedDB) → WebSocket server nodes → Redis fan-out → WebSocket server nodes

Architecture: Tandem

Components

Request flows

Choose a component to focus it, or a flow to watch its requests. In the view, drag or use the arrow keys to turn it and + or − to zoom. Escape closes.

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.

Questions?

Read the full write-up