Skip to content
all notes
6 min read

Why I stopped locking documents and started merging them

The obvious way to build a collaborative canvas is to make the server the referee. Every edit goes up, the server decides whether it's allowed, and the result comes back down. It's easy to reason about, and it's wrong for anything that needs to feel instant.

The problem with asking permission

If the client has to wait for the server to confirm an edit, then every interaction costs you a round trip. On a good connection that's 40ms and nobody notices. On a train it's 800ms and dragging a sticky note feels like dragging it through mud. Worse, the moment two people touch the same object you need a conflict story, and the honest ones all end in someone losing work.

What a CRDT actually buys you

A conflict-free replicated data type is a structure where the merge operation is commutative, associative and idempotent. In plain terms: it doesn't matter what order changes arrive in, it doesn't matter how they're grouped, and applying the same change twice is harmless. Given those three properties, any two replicas that have seen the same set of operations are guaranteed to agree — without ever talking to a referee.

That guarantee is what lets you apply edits locally first. The interface responds immediately, the operation goes out asynchronously, and convergence is a property of the maths rather than a promise from the server.

The rule that resolves ties

Nori uses Yjs, but the demo on my homepage is a smaller structure I wrote by hand to make the idea visible: every field is a last-writer-wins register carrying a Lamport clock and an actor id. Higher clock wins; equal clocks break on actor id. Because that comparison is a total order over every possible pair, the outcome is identical on every replica.

function dominates<T>(incoming: Register<T>, current?: Register<T>) {
  if (!current) return true
  if (incoming.clock !== current.clock) {
    return incoming.clock > current.clock
  }
  // Deterministic tie-break — never a coin flip.
  return incoming.actor > current.actor
}

That tie-break is the part people skip, and it's the part that matters. If two edits land on the same field with the same clock and you resolve them by arrival order, your replicas will disagree — and they'll disagree silently, which is the worst kind.

What you give up

Convergence is not the same as correctness. A CRDT guarantees everyone ends up with the same document — not that it's the document anyone wanted.

For a canvas, that trade is clearly worth it: the cost of a slightly wrong merge is that someone moves a sticky note back. For a bank ledger it obviously isn't. Knowing which problem you have is most of the work.

CRDTYjsArchitecture