Design a Collaborative Editor — the walkthrough in full
A written version of the interactive walkthrough above — the same steps, decisions and trade-offs, laid out for reading, reference and search.
The big idea
What is a collaborative editor?
Two people open the same document and type at the same time. Naively, the last save wins and overwrites the other person's words — the exact opposite of "collaborative." How do you let N people mutate the same shared state concurrently and have everyone converge on the same final document, with no one's work silently vanishing?
The whole problem is concurrency control on shared, structured state — not just storage. Two lineages of solution exist: Operational Transformation (transform each op against every concurrent op it wasn't aware of) and CRDTs (design the data structure so merges are mathematically conflict-free). We'll build both, in order, and see exactly why the industry moved from the first to the second.
How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the diagram grow, hover the boxes, and at the end kill the sequencer and kill the relay to see which architecture actually survives. Hit Begin.
Step 1 · The baseline
One writer at a time
Start with the simplest thing that can't corrupt anything: only one person may edit at a time, enforced by a lock. Editor A holds the lock, types, releases it; then B can take a turn. It's correct. What's wrong with it?
Design decision: A whole-document lock, one writer at a time. What breaks in practice?
The call: It kills concurrency: everyone but the lock-holder is frozen, and the lock itself is a bottleneck and a single point of failure. — A document-wide lock serializes every collaborator through one holder at a time. Real documents have 2, 20, or 200 simultaneous editors; a turn-based lock makes that experience unusable, and whoever holds the lock becomes a failure point for everyone else.
A whole-document lock is correct but useless for real collaboration: it turns N simultaneous editors into a queue, and the lock-holder becomes both a bottleneck and a single point of failure. Real collaborative editors need true concurrent writes — everyone typing at the same instant into the same document.
Locking trades safety for concurrency: Pessimistic locking (grab the whole resource) is the easy, wrong answer for anything meant to be simultaneous. The real question a collaborative editor must answer is: how do multiple concurrent, unordered writes merge into one document without a lock forcing them into a queue?
Step 2 · Concurrent chaos
What happens if you just… let them both write?
Drop the lock. A inserts "quick " at position 4; at the same instant B inserts "brown " at position 4 too — neither has seen the other's edit yet. Both send their op to the relay, which naively applies each at its recorded position. What comes out?
Design decision: Both ops target position 4, applied naively in receipt order. What's the result?
The call: Garbled or interleaved text, and possibly a crash, because the second op's position 4 no longer means what it meant when the op was created. — A inserted at what WAS position 4; the moment B's op also lands at position 4, the document has shifted underneath it. Applying B's op literally at index 4 now either splits A's new word in half, drops characters, or points past the end of a shorter string. Position-based ops go stale the instant a concurrent op is applied first.
Naive replay corrupts the document: an op's position is only valid against the document state at the moment it was created. The instant a concurrent op lands first, every other pending op's position is stale. You need either (a) to transform each op against every concurrent op it didn't know about, or (b) design the data structure so position never has to be a raw, brittle integer index.
The core conflict problem: This single failure — "my op's coordinates go stale the moment someone else edits concurrently" — is the entire reason collaborative editing is hard. Every real solution is an answer to exactly this question, and the two dominant answers are OT and CRDTs.
Step 3 · Operational Transformation
Transform ops against each other
Google Docs' original answer: keep raw position-based ops, but before applying a remote op, mathematically transform it against every local op it wasn't aware of, shifting its position to account for what already landed. That transform function needs everyone to agree on ONE global order of ops to transform against. Who assigns that order?
Design decision: OT needs a single agreed order of ops to transform against. Who decides that order?
The call: A central sequencer stamps every incoming op with the next version number; everyone transforms against that agreed order. — A single server-side sequencer assigns a monotonic version to every op the instant it arrives. Every client transforms its pending local ops against remote ops using that shared version number, guaranteeing all replicas apply the same logical sequence and converge.
Introduce a central OT sequencer: every op is sent to it first, stamped with the next global version number, and broadcast in that order. Clients keep a local buffer of un-acknowledged ops and transform incoming remote ops against them (and vice versa) using position-adjusting transform functions, so both editors converge on an identical document.
Transform, don't just replay: OT's core primitive is transform(opA, opB) → opA': given two concurrent ops, produce a version of opA that accounts for opB already having been applied. Do this pairwise against the whole pending buffer and convergence holds — as long as everyone agrees on the same global order to transform against.
Step 4 · OT's cost
The sequencer is a bottleneck — and the math is brutal
OT works — Google Docs proved it at scale. But look at what you just built: every single op, from every client, must round-trip through one server before it can be finalized. And the transform functions themselves have to handle every op-type pair (insert-insert, insert-delete, delete-delete, formatting…) correctly, including edge cases nobody notices for years.
Two real costs. First, the sequencer is a single point of ordering — lose it and no op can be finalized anywhere, even though clients are still online and reachable to each other. Second, transform functions are notoriously hard to get exactly right: correct OT for rich text (bold spans, moved blocks, nested lists) is a research-grade problem, and a single subtly wrong transform case can silently desync two documents forever.
Correct in theory, fragile in practice: OT is provably correct if the transform functions satisfy strict properties (TP1/TP2) for every op-type pair — and real editors have dozens of op types. Getting this exactly right, and keeping a central sequencer in the hot path of every keystroke, is the price of OT's design.
Step 5 · CRDTs
Make the merge itself conflict-free
What if positions never went stale in the first place — no matter what order ops arrive in, no matter who's online? Instead of "index 4," give every character a position that's meaningful forever, derived from its causal history rather than its offset.
Design decision: How do you make merges conflict-free without a central sequencer at all?
The call: Give every character a unique, immutable identity (a CRDT position) derived from causal history, so any two replicas merge by union — same result regardless of arrival order. — A sequence CRDT (e.g. RGA) assigns each inserted character a globally unique id built from {author, logical clock, reference to its left neighbor at insert time}. Two replicas can apply the SAME set of ops in ANY order and always converge to the identical document — merge is just "union of known ops, sorted by id," with no central authority and no per-op transform math.
Switch to a CRDT (Conflict-free Replicated Data Type) — specifically a sequence CRDT like RGA or Peritext. Every character gets a globally unique, immutable id derived from who inserted it, a logical clock, and its left neighbor at insert time. Two replicas can apply the exact same set of ops in any order and always converge to the same final document — this property is called strong eventual consistency, and it needs no central sequencer at all.
CRDTs vs OT: OT keeps positions as raw offsets and repairs them with transform functions against an agreed order. CRDTs make positions never go stale by construction — merge becomes a commutative, associative, idempotent operation (a mathematical join), so any arrival order gives the same result. That's why Notion, Figma and Automerge moved to CRDTs.
Step 6 · Presence & cursors
Not everything belongs in the document
Users also want to see each other's cursor and selection live. That's high-frequency (every mouse move), totally disposable (nobody cares about cursor history from an hour ago), and per-session (it vanishes the moment someone closes the tab). Should it live in the same CRDT as the document text?
No — keep presence on a separate ephemeral channel, entirely outside the document's CRDT and its durable history. Broadcast cursor/selection updates directly through the relay to other online peers; never persist them, never version them, and never let them touch the op log that the document's correctness depends on.
Durable state vs ephemeral state: Mixing "where is your cursor right now" into the same conflict-free, permanently-merged structure as document content would bloat history with meaningless noise and complicate merge logic for zero benefit. Separate the two: one channel for content that must survive forever and merge correctly, one for state that's valid for exactly as long as the tab is open.
Step 7 · Offline & local-first
Keep typing when the network is gone
Editor A's WiFi drops mid-sentence. Should the app freeze, buffer nothing, and lose their next five minutes of typing?
Design decision: Editor A goes offline mid-edit. What should the client do?
The call: Apply every edit immediately to the LOCAL CRDT replica (optimistic), queue the ops, and sync/merge automatically the moment connectivity returns. — Because the CRDT merge is order-independent and needs no server round trip to be locally valid, the client can apply edits to its own replica instantly and keep a durable queue of unsent ops. On reconnect, it exchanges queued ops with peers and merges — no manual conflict resolution UI needed, because convergence was guaranteed by construction.
This is where CRDTs pay off a second time: because merges are commutative and order-independent, a client can apply every edit to its local replica immediately (zero-latency typing, even fully offline) and simply queue unsent ops. On reconnect, it exchanges the queue with peers and the CRDT merges automatically — no conflict-resolution dialog, because the data structure guarantees the same convergent result regardless of how long anyone was offline.
Local-first software: "Optimistic local apply + eventual sync" is the local-first pattern popularized by Ink & Switch and libraries like Automerge and Yjs: the local replica IS the source of truth for the user's own edits in the moment, and the network is just how replicas eventually agree — not a gate they wait on.
Step 8 · The sharp edge
CRDTs never truly delete — tombstones grow forever
A CRDT can't just remove a deleted character from its internal structure, because a concurrent, not-yet-seen op might still reference that character's id (e.g. "insert after character X") — deleting X outright would break the merge. So every delete becomes a tombstone marker instead of a real removal. What happens to a heavily-edited document after a year?
Tombstones accumulate forever unless you actively manage them: metadata for every character ever deleted stays in the structure, so a document that's been edited for a year can carry far more tombstone metadata than live content. The fix is garbage collection via version vectors — once every replica has acknowledged it has seen a tombstone (no one can possibly still reference it), it's safe to prune. Combine that with periodic snapshotting so new clients load a compacted document instead of replaying the entire op log from time zero.
Design for the unhappy path: CRDTs trade "no central sequencer" for "history grows unless you compact it." Real production CRDT editors run background compaction: prune tombstones once all replicas have converged past them, and periodically snapshot the document so op-log replay stays bounded. A CRDT that never compacts is a demo; one that does is a product.
You did it
You just designed a collaborative editor.
- A —
- N — a
- O — T
- C — R
- P — r
- O — p
- T — o