← Back to engineering notes

Distributed state

How I test Yjs convergence with 10 clients and 1,000 operations

A seeded network simulation for duplicate delivery, reordering, reconnects, restart recovery, and user-local undo.

July 2026 · 3 min read

01

Two browser tabs were not enough

Two browser windows are enough for a collaboration demo. They tell me almost nothing about convergence. Real updates arrive late, turn up twice, interleave, and cross reconnects. I needed a narrower property I could test: once every accepted update has arrived, every logical replica should contain the same canonical graph.

In System Synthesis, nodes and edges live in a Yjs document. I store each entity as a nested map rather than one opaque JSON value, so a rename and a position change can merge independently instead of replacing the whole node.

02

Turn network chaos into a replayable test

The harness uses a seeded pseudo-random generator. A run still gets varied clients, delays, duplicates, and delivery orders, but a failing seed lets me replay the exact sequence. The randomness widens coverage; the seed makes the failure debuggable.

  • Create 10 client documents plus two logical server documents.
  • Generate 1,000 graph operations including add, remove, rename, move, reconnect, and user-local undo.
  • Encode every Yjs update and place it in a simulated delivery queue.
  • Inject duplicate messages and deliver portions of the queue out of order.
  • Simulate a server restart by rebuilding from durable state and replaying the ordered tail.
  • Drain the queue, canonicalize every graph, and compare hashes.
for (const operation of seededOperations(1000)) {
  const update = applyOperation(clients[operation.client], operation)
  network.enqueue(update, {
    duplicate: random.boolean(0.12),
    delay: random.integer(0, 20),
  })

  if (operation.checkpoint) network.deliverRandomBatch()
}

network.drain()
expect(canonicalHashes(allReplicas)).toHaveSize(1)

03

Compare the graph, not Yjs internals

Raw Yjs encodings are the wrong equality check. Their internal item order can differ even when users see the same graph. I project each document into plain graph data, sort nodes and edges by stable IDs, normalize optional fields, and hash that serialization.

Equal hashes alone would still let a bad test pass if every replica lost the same data. The harness also checks graph invariants and the expected effect of each acknowledged operation.

04

Keep undo local to its author

One user's undo should never reverse somebody else's work. I configure Y.UndoManager to track only transactions created with the local user's origin, then mix local undo into the same delayed and reordered delivery runs. Remote changes must remain present.

This separates structural convergence from product behaviour. Yjs can make replicas agree, but the application still has to decide whose work an undo command may reverse.

05

What this still does not prove

A passing run is not a formal proof of every possible interleaving, and it says nothing about live Socket.IO capacity. What it gives me is a repeatable, high-variance check of the collaboration model in the repository.

  • It does not preserve both users' semantic intent when they edit the same scalar field.
  • It does not make the product offline-first; editing pauses while disconnected.
  • It does not replace authenticated API and WebSocket integration tests.
  • It does not measure latency or production throughput.
← All engineering notesDiscuss an opportunity →