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

SDE

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
Line chart of Tandem's edit-to-remote p95 latency by editors on one board: 12 ms at 20 editors, 47 ms at 50, 186 ms at 150, crossing the 300 ms target between 150 and 200, and 938 ms at 300

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.

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.

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.

pssaikumardangeti@gmail.com