Convergence
The previous lesson left two clients divergent: one note, two outboxes, edits the server has never seen. Now we let them sync — and collect the payoff the whole architecture was built for. Both clients pull each other’s ops, merge them through the WASM CRDT, and land on byte-for-byte identical text, with no “which version do you want?” prompt anywhere.
Then we prove why it’s safe, not just that it worked this once: the merge is commutative (order doesn’t matter) and idempotent (repeats don’t matter). Those two properties are the entire guarantee. We close by naming the cost — tombstones — honestly.
What we’re building
Section titled “What we’re building”Still a lab, no new app code. First, bring both clients online and confirm text() is identical. Then a small console experiment that merges the same ops in different orders and twice over, showing the result never changes. Finally, a look at the tombstones the deletes left behind.
“Eventually consistent” is easy to say and easy to fake — a single lucky sync proves nothing. What makes a CRDT trustworthy is that convergence is a property of the data structure, not of timing. Because merge is a set-union of ops ordered deterministically, it doesn’t matter whether Client A’s ops arrive before or after Client B’s, or whether a flaky network delivers the same batch twice: every replica that has seen the same set of ops computes the same document. That’s what lets the thin server stay dumb — it never merges, never resolves, just relays — and what lets Background Sync retry a push blindly without fear of double-applying. Demonstrating commutativity and idempotence by hand is how you earn the right to rely on all of that.
Pros & cons
Section titled “Pros & cons”CRDT merge vs. last-write-wins
- Pros: Both clients’ edits survive and interleave; no edit is silently discarded, and no user is ever asked to pick a winner.
- Cons: Convergence means agreeing, not being right — the merged order is deterministic but may not match either author’s intent character-for-character. It resolves conflicts; it doesn’t read minds.
Set-union of ops (commutative) vs. an ordered log you must replay in sequence
- Pros: Ops can arrive in any order, be retried, or be duplicated, and the result is identical — which is exactly what an unreliable network and a retrying
syncevent hand you. - Cons: Every op must carry enough identity (its
(counter, actorId)) to be placed deterministically, and deletes must linger as tombstones so a late-arriving insert still knows where it belongs. That metadata grows.
Set it up
Section titled “Set it up”1. Sync both clients
Section titled “1. Sync both clients”Pick up the two offline clients from the previous lesson. In each profile, DevTools → Network → Online. Each client now pushes its outbox and pulls the other’s ops (Module 10 / Background Sync), then merges:
// apps/web/src/sync.ts (already built, Module 10) — the merge step, for referenceconst { ops, head } = await pullSince(noteId, lastPulled);doc.merge(ops.map((e) => e.op)); // WASM CRDT applies remote opsawait putDoc(noteId, doc.snapshot());await setMeta(`pulled:${noteId}`, head);2. Compare text() on both
Section titled “2. Compare text() on both”In each profile’s console, read the live document’s text through the store’s NoteDoc:
// however your store exposes the open doc; e.g. window.__store.doc(noteId)__store.doc('<noteId>').text();Verify
Section titled “Verify”Both clients return the same string — the two concurrent edits interleaved deterministically:
Client A: "Meeting notes: alpha beta"Client B: "Meeting notes: alpha beta"They match exactly, and neither client was prompted to resolve anything. Now prove it wasn’t luck — a self-contained experiment against the WASM engine, order and repetition varied on purpose:
import init, { NoteDoc } from '../../crates/crdt/pkg/crdt.js';await init();
// Capture both clients' op batches (from each outbox, previous lesson).const opsA = [ /* Client A's ins ops for " alpha" */ ];const opsB = [ /* Client B's ins ops for " beta" */ ];const base = [ /* the shared "Meeting notes:" ops */ ];
// d1: base, then A, then B.const d1 = new NoteDoc('probe-1');d1.merge([...base, ...opsA, ...opsB]);
// d2: the SAME ops, reversed order — commutativity.const d2 = new NoteDoc('probe-2');d2.merge([...base, ...opsB, ...opsA].reverse());
// d1 again, applying everything a SECOND time — idempotence.d1.merge([...base, ...opsA, ...opsB]);
console.log(d1.text() === d2.text()); // true — order didn't matterconsole.log(d1.text()); // unchanged by the repeatExpected: true, and d1.text() is identical before and after the repeated merge. Reordering the ops changed nothing; applying them twice changed nothing. That is commutative + idempotent convergence, demonstrated rather than asserted.
Confirm the queues are empty and the server holds the union:
curl "http://localhost:8787/docs/<noteId>/ops?since=0"# { "ops": [ ...base + A's ops + B's ops... ], "head": N } # both outboxes now emptyThe tombstone caveat. Delete some characters in either client and re-inspect the document’s snapshot: the deleted elements don’t vanish — they become tombstones, kept so a late insert that referenced them still resolves. Ops and tombstones only grow; this hand-rolled RGA has no compaction. That’s the deliberate simplification from A CRDT in Rust — production engines like Automerge and Yjs garbage-collect this history; ours doesn’t. Name it whenever you reach for a CRDT: convergence is paid for in metadata.
Finally, the build stays clean:
pnpm --filter web build # Complete!Check your understanding:
- Both clients showed
"Meeting notes: alpha beta". What decided that A’s text came before B’s, given neither client saw the other while editing? - The experiment merged the same ops twice and in reversed order, yet
text()never changed. Which two CRDT properties does each of those tests exercise? - Because merge is idempotent, what does that let the Background Sync
syncevent do safely that an ordered-log approach could not? - A deleted character becomes a tombstone instead of disappearing. Why is keeping it necessary for correctness, and what long-term cost does that impose?
The payoff, earned and demonstrated: two devices edited one note offline and converged on identical text with zero conflict prompts — and we proved convergence holds regardless of op order or repetition, the properties that let the server stay thin and sync stay retry-safe. The cost is tombstones that grow without compaction, a limit you can now name and defend. With the CRDT proven end to end, it’s time to ship. Next: Deployment → takes the PWA and the sync server to production.