✍️ System Design collaborative editing
✍️ Part IV · Modern Case Studies · chapter 22 / 24

Many editors,
one convergent doc

Two people type at the same spot at the same instant — how do both edits survive, and how does everyone end up staring at the exact same document? This chapter builds up the answer from naive saves that lose work to the two real techniques: operational transform and CRDTs.

Open a shared doc, put three people in it, and have them all type in the same paragraph. Somehow nobody's words get clobbered, and a second later all three screens show an identical document. That "somehow" is the entire subject of this chapter.

The naive instinct — "just save the document" — falls apart the moment two people edit at once, because one person's save quietly overwrites the other's. To make live collaboration actually work, we stop shipping whole documents and start shipping tiny edits ("insert x at position 5", "delete the character at 9"), and we give the system a rule for combining edits that arrive in any order so that everyone lands on the same final text.

There are two industrial-strength rules for doing that. Operational Transform (OT) reshapes each incoming edit so it still makes sense after the edits that raced ahead of it. CRDTs redesign the data itself so that edits simply merge, in any order, with no reshaping needed. We'll build intuition for both, prove to ourselves that they converge, then wire up transport, presence, and persistence around them.

1The problem: many editors, one live document

A collaborative editor is a single document that several people change at the same time, live. Each keystroke from each person needs to show up on everyone else's screen within a moment, and — this is the hard part — when the dust settles, every screen must show exactly the same characters in exactly the same order. That property is called convergence.

What makes it hard is that edits race. Alice types an H at the start of a line while, at literally the same time, Bob types a W at the start of the same line. Neither saw the other's keystroke yet. Both edits are valid. Both must survive. And after they're combined, Alice's screen and Bob's screen must agree — it can't be that Alice ends up with HW… and Bob with WH….

  • Concurrent edits — two changes made without either author seeing the other. There's no "true" order; the system has to invent a consistent one.
  • No lost work — every accepted keystroke has to appear in the final document. Silently dropping one is the cardinal sin.
  • Everyone converges — given the same set of edits, every replica must compute the same document, regardless of the order edits arrived in.

So the real question of this chapter is narrow and sharp: two people type at the same spot at the same instant — how do both edits survive and everyone end up seeing the same document?

🎯
Hold onto one phrase: "same edits, any order → same result." Every technique in this chapter is just a different way to guarantee that one sentence.

2Why naive "last write wins" loses data

The obvious design is the one almost everyone reaches for first: each client keeps its own copy of the document, and when you make a change you send the whole document back to the server. The server keeps whichever copy arrived most recently. This is last-write-wins (LWW) on the whole document.

It works perfectly — for one person. The instant two people edit concurrently, it destroys work. Suppose the doc is "cat". Alice changes it to "cats" and saves. A half-second later Bob — who started from "cat" too and never saw Alice's s — changes his copy to "scat" and saves. Bob's save lands last, so the server now holds "scat". Alice's s is simply gone. No error, no conflict marker — her edit was overwritten wholesale.

The bug isn't that the server picked the wrong winner. The bug is that the unit of saving is the entire document. When you overwrite the whole thing, you can't keep two concurrent changes; one blob replaces the other. The fix is to shrink the unit: send operations — "insert s at position 3", "insert s at position 0" — and merge them at the level of individual characters. Both inserts can survive because they touch different things.

⚠️
Whole-document saves are a data-loss machine in collaboration. LWW is fine for a single field edited by one user (a profile name), but for a shared document it silently eats concurrent edits. You need operation-level or character-level merging, not blob replacement.

3Operational Transform — reshape edits so they still fit

Here's the trouble with shipping raw operations: a position is only meaningful against a specific version of the text. Alice says "insert x at position 5." But before that op reaches Bob, Bob already inserted three characters near the front. Now Bob's document is longer and shifted — applying "insert at 5" literally would land in the wrong place. The op needs to be adjusted for the edits that happened concurrently.

That adjustment is operational transform. The core idea is a function — call it transform(a, b) — that takes two concurrent operations and rewrites one of them so it produces the right effect as if the other had already been applied. If Bob inserted ahead of Alice's target position, Alice's "insert at 5" becomes "insert at 8." Both edits survive; positions stay correct.

A central server orders the operations. Every client sends its ops to the server; the server picks an authoritative order, transforms each incoming op against the ops it has already accepted, applies it, and broadcasts the transformed op to everyone. Each client likewise transforms server ops against any local ops still in flight. This is the approach Google Docs historically used, and it's efficient — the only thing traveling over the wire is a small op plus a version number.

The catch: OT is famously tricky to get right. You need a correct transform function for every pair of operation types (insert vs insert, insert vs delete, delete vs delete…), and the transforms must satisfy algebraic properties so that no matter which order two clients apply things in, they land on the same text. Subtle bugs in those rules cause silent divergence, which is exactly the failure you were trying to prevent.

λ
The FP framing: a document is a value, an operation is a function Doc → Doc, and transform is what makes those functions commute. OT manufactures commutativity by editing the operations; the next idea, CRDTs, bakes commutativity into the data so you never have to.

4CRDTs — design the data so edits just merge

OT keeps the data simple (it's just a string) and pays the price in clever transform logic. CRDTs — conflict-free replicated data types — flip the trade. They make the data structure cleverer so that concurrent edits merge deterministically no matter what order they arrive in, with no transform step and no central coordinator required.

The trick for text is to stop thinking of a document as "characters at positions 0, 1, 2…" — those positions shift every time someone inserts. Instead, give every character a permanent, globally unique id that also encodes where it belongs in the order. Two common schemes:

  • Fractional indexing — instead of integer positions, use fractions. To insert between the characters at 0.25 and 0.50, you pick 0.375. There's always room between any two fractions, so an insert never needs to renumber its neighbors.
  • RGA / sequence CRDTs — each character carries an id like (timestamp, siteId) and a pointer to the character it was inserted after. Ordering the characters by these ids deterministically reconstructs the text on every replica.

Once every character has a stable unique id, merging two replicas is almost embarrassingly simple: take the union of their characters (dedupe by id), then sort by id to read out the text. Two people inserting "at the same position" actually inserted characters with two different ids, so both survive, and the total order on ids decides their final sequence — identically on every machine. Deletes are handled by marking a character as a tombstone rather than removing it, so a delete and a concurrent insert can't disagree.

🧩
The mental shift: positions are not identity. A character isn't "the 5th slot," it's "the character with id (1234, A)." Ids never move, so concurrent edits never fight over a slot — they just both exist, and the id order sorts them out.
Interactive · two clients, concurrent inserts Same two edits, applied in opposite orders
pick a mode, then apply
client A sees
cat
client B sees
cat
converged?

Both clients start from "cat". Client A inserts s at the front; client B inserts ! at the end — but the two ops reach the clients in opposite orders. In Naive LWW a whole-doc save wins and an edit vanishes (or the two ends disagree). Switch to OT / CRDT and both edits survive in the same order on both screens.

5The convergence guarantee — a join in disguise

Strip away the implementation and OT and CRDTs are aiming at the identical target: "same operations, any order → same result." OT reaches it by transforming ops until applying them in any order produces the same text. CRDTs reach it by defining a merge that's commutative (order doesn't matter), associative (grouping doesn't matter), and idempotent (re-merging the same thing changes nothing).

Those three properties should ring a bell. A merge that's commutative, associative, and idempotent is exactly a join on a semilattice — the structure we met back in Chapter 7. The set of all possible document states forms a lattice, and merging two replicas is taking their least upper bound: the smallest state that contains everything both replicas have seen.

This is why CRDTs need no coordinator. Because merge is a join, it doesn't matter who merges with whom, in what order, or how many times. Replica A merging B's state, then C's, lands in the same place as C merging B then A. Convergence isn't something you carefully orchestrate — it's a mathematical consequence of the merge being a join. Monotonic progress up a lattice can't disagree with itself.

λ
Callback to Ch.7: a CRDT's state is a semilattice and merge is its join (least upper bound). Commutative + associative + idempotent = order-, grouping-, and repeat-independent = guaranteed convergence with zero coordination. The algebra is the correctness proof.
Interactive · merge = union of id'd characters Toggle edits on each replica, then merge
merged text (sort by id)
commutative?
A⋈B = B⋈A
idempotent?
M⋈M = M

Each replica is a set of positioned characters (id, ch). Merge takes the union, drops duplicate ids, and sorts by id to read the text. Because union is commutative, associative, and idempotent, both replicas land on the same string — and merging the same state twice changes nothing. That's the join from §5, made concrete.

6Transport & presence — pushing edits and cursors live

Convergence answers "what do we do with edits"; transport answers "how do edits get there fast." Collaboration is bidirectional and latency-sensitive — polling for changes would feel laggy — so edits travel over a persistent WebSocket connection (the full-duplex channel from Chapter 10). A client opens one socket to the server, streams its ops up as you type, and receives everyone else's ops streamed down, applying each as it arrives.

But a good collaborative editor shows more than text. You see the other people: their colored cursors, their selections, a little flag with their name, who's currently viewing. This layer is called presence or awareness, and it has different rules than the document itself.

  • Cursors are ephemeral — they don't need to be saved or perfectly converged. If a cursor update is dropped, the next one fixes it. So presence is usually sent as fire-and-forget broadcasts, separate from the durable op stream.
  • Edits shift other people's cursors — if Alice inserts three characters before Bob's cursor, Bob's cursor must move three to the right to stay on the same character. The same position-transform logic that fixes ops also fixes remote cursors.
  • Awareness times out — when someone closes the tab, their socket drops; after a short heartbeat timeout the server tells everyone to remove that person's cursor.
Interactive · cursor & presence sync Edits shift everyone's cursors over a simulated socket
ws: idle

Three editors share one line of text, each with a colored cursor broadcast over a simulated WebSocket. Hit Live cursors to watch them drift as their owners type. Then have Alice insert "ABC" at the start: notice Bob's and Carol's cursors jump three characters right, so each stays glued to the same character it was on.

7Persistence & history — the op log, snapshots, offline

The live session is in memory, but the document has to outlive it — survive a server restart, let a latecomer join and catch up, power version history and undo. The durable record is an append-only operation log: every accepted op, in order, written to storage. Replaying the whole log from the start reconstructs the document exactly, which doubles as your edit history and the basis for "see changes" and revert.

  • Snapshots + compaction. Replaying millions of ops to open a doc is slow, so periodically save a snapshot of the full document plus the version number it represents. To load, take the latest snapshot and replay only the ops after it. Old ops can be compacted away (kept in cold storage if you still want full history).
  • Offline edits. A client that loses its connection keeps editing locally, queuing its ops. On reconnect it ships the queued ops and receives everything it missed; OT transforms them into place, or the CRDT simply merges them. Because merge is a join, an offline burst replayed on reconnect cannot corrupt the document — it just moves up the lattice.
  • Catch-up for joiners. A new participant is handed the latest snapshot, then subscribes to the live op stream from that version forward — no need to download years of history.

This is the same shape as event sourcing: the log is the source of truth, snapshots are a performance cache of folding that log, and the current document is just log.foldLeft(empty)(apply).

λ
The FP framing: the document is a left fold over the op log. A snapshot memoizes a prefix of that fold; offline replay is just folding the tail you missed. Pure, replayable ops are what make snapshots, history, undo, and reconnection all fall out of the same one idea.

8Tradeoffs — OT vs CRDT, and when each fits

Both converge. The choice is about where you pay the cost and what shape your system has.

  • Operational Transform keeps the data tiny — it's just a string, and ops carry almost no metadata. But it leans hard on a central server to order and transform every op, and the transform functions are intricate and easy to get subtly wrong. Best when you already have an authoritative server in the loop and you want minimal per-character overhead (classic server-mediated docs).
  • CRDTs need no central transform and merge peer-to-peer, which makes them a natural fit for offline-first, local-first, and decentralized apps. The cost is more metadata — every character drags around a unique id, and deleted characters linger as tombstones — so memory and payload are larger (modern encodings shrink this a lot). Best when you want robust offline editing, peer sync, or simply don't want to trust one server to never miscompute a transform.

A rough rule of thumb: central server you already trust + bandwidth-sensitive → OT; offline-first, local-first, or peer-to-peer + willing to spend memory → CRDT. Here's the whole chapter as a checklist:

  1. 1Edits, not documents. Ship operations / characters, never whole-doc saves — that's what stops LWW from eating concurrent work.
  2. 2Pick a convergence rule. OT transforms ops to commute; CRDTs make the data merge by construction.
  3. 3Guarantee "any order → same result." For CRDTs this is a join on a semilattice — commutative, associative, idempotent.
  4. 4Stream over WebSockets. Push ops both ways live; keep latency low.
  5. 5Add presence. Broadcast ephemeral cursors/selections; shift them when edits move text.
  6. 6Persist the op log. Append every op; snapshot + compact so loads and joins are fast.
  7. 7Handle offline. Queue local ops, replay on reconnect; merge can't corrupt the doc.
  8. 8Choose by shape. Server-mediated & lean → OT; offline/peer-friendly & metadata-rich → CRDT.

9A sequence-CRDT merge, as set union + sort

Here's the heart of §4 and §5 in code: a character is an id paired with a glyph, ids have a total order, and merge is "union the two replicas, drop duplicate ids, sort by id." That's it — and that one line gives you convergence with no central coordinator, because union-then-sort is commutative, associative, and idempotent (it's the join from Ch.7). Flip between the languages; the shape is identical.

λ
Deterministic id order + set union = convergence. No server has to order or transform anything: any replica can merge any other, in any order, any number of times, and they all land on the same text. The merge is a join; the join is the proof.