The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
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.
What the new pieces do
- Editor Aclient
- One collaborator, editing the document from their own device. Wants their keystrokes to appear instantly, locally, before any round trip.
- Editor Bclient
- A second collaborator editing the very same document, at the very same moment, a few words away.
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?
A whole-document lock, one writer at a time. What breaks in practice?
It IS correct, but "collaborative editing" implies simultaneity. If B has to wait for A to finish a paragraph before typing a single character, you haven't built a collaborative editor — you've built a check-out queue.
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 lock is a few bytes of state — disk isn't the issue. The issue is concurrency: real collaboration needs everyone typing at once, not queued.
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.
Why this piece earns its place
Every rescue of the lock lands you back in the same place. Shrink its granularity — a lock per paragraph or per block rather than the whole file — and it looks fixed right up until two people work on the same sentence, which is the case collaboration exists for; shrink it further, to a word or a character, and every keystroke needs a round trip to acquire something before it can render. The other direction is worse: a lock the size of the document is a queue with extra steps. Then there is the holder. A lock assumes whoever took it is still there, and tabs crash, laptops sleep and trains go into tunnels, so in practice you bolt on an expiring lease — and the instant a lease expires under someone who is still typing, two writers own the same region and you get exactly the corruption the lock was bought to prevent.
What the new pieces do
- Relayrouting
- A WebSocket hub that fans every op out to every other connected client in the document's room. A transport, not an authority.
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?
Both ops target position 4, applied naively in receipt order. What's the result?
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.
There is no way to rank two valid, simultaneous insertions as better or worse — both are correct edits from their author's point of view. The system has no basis to discard either.
They only apply cleanly if you rewrite each op's target position to account for the other, concurrent op that already landed — which naive position-replay does NOT do. That rewriting is exactly what's missing here.
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.
Why this piece earns its place
The tempting cheap fix is to stop arguing about position and just order the ops: stamp each one with a timestamp, apply them everywhere in that order, last write wins. That is the right answer for a different shape of data. Last-write-wins suits a register — a title, a cell, a flag — where the unit you overwrite is the whole value and discarding the loser is the intended behaviour. A document is a sequence, and it needs both edits to survive and to land in the right places. A total order delivers neither: every op in it still carries a raw offset computed against a document nobody else had, so replaying them in a tidy agreed sequence mangles the text just as thoroughly as replaying them in receipt order. And the timestamps themselves are not evidence — two clients’ clocks disagree, so first is not something you can read off the wire.
- 0solutions so far
- 2concurrent inserts
- ∞ways to corrupt naive replay
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?
OT needs a single agreed order of ops to transform against. Who decides that order?
If every peer decided order independently, different peers could see ops in different orders and transform differently — that's exactly the divergence OT exists to prevent. Order must be agreed globally, not locally.
Running distributed consensus per keystroke is far too slow for something meant to feel instant. OT was designed specifically to avoid this cost.
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.
Why this piece earns its place
The knob this step introduces is the client’s buffer of unacknowledged ops, and it misbehaves precisely when the network does. Each arriving remote op is transformed against everything still pending locally, so a slow round trip costs you twice: the buffer keeps growing while the user keeps typing, and the transform work per arriving op grows with it. The client on the worst connection ends up doing the most work, exactly when it can least afford it. So have every acknowledgement carry the version it assigned and prune the buffer on each ack rather than on a timer, and put a ceiling on it. What happens at the ceiling is a real decision, not an error case: you stop transforming and resynchronise from the server’s state, which means explicitly reconciling — never silently dropping — whatever was typed while the client was away. This is also why OT’s offline story is thin: the longer the gap, the more expensive and the lossier the return.
- 1central sequencer
- O(n²)transform pairs in the worst case
- ✓this is how Google Docs shipped
What the new pieces do
- OT Sequencercontrol
- The Operational-Transformation era's central authority: stamps every incoming op with the next version number so everyone transforms against the same total order.
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.
Why this piece earns its place
The assumption holding all of this up is that a broken transform announces itself. It does not. A wrong case in one op-type pair throws no exception and fails no request; it produces two documents that quietly stopped being the same document, each perfectly coherent to its own author. Nobody notices until someone reads a paragraph over a colleague’s shoulder and finds a word that is not on their screen — by which point the session is long gone and there is nothing to reproduce. So build the detector yourself: have each client send a cheap checksum of its document state alongside the version it has applied, and compare checksums at equal versions server-side. Divergence then surfaces at the version where it began, which is the single piece of evidence that makes a transform bug findable at all. And when two checksums disagree, resynchronise from the authoritative state rather than attempting a repair — a document that has already diverged cannot be transformed back into agreement.
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.
How do you make merges conflict-free without a central sequencer at all?
A replicated sequencer improves availability but keeps the fundamental dependency: ops still need one agreed order before they're valid. That's HA-OT, not a structurally different approach.
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.
Retrying doesn't solve WHAT should happen when two inserts genuinely target the same spot — it just delays the same undefined-order problem. The fix is making the data structure itself order-independent, not retrying against it.
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.
Why this piece earns its place
Worth being precise about what this guarantee buys, because it oversells easily. It promises every replica reaches the same document. It promises nothing about that document reading the way either author meant. Two people typing at the same spot at the same moment produce ids whose deterministic sort can weave their characters into each other, and the property still holds perfectly: everyone agrees on the woven result and nobody wanted it. Convergence is agreement, not intent. That makes the choice of sequence CRDT a design decision rather than a library preference, because what actually differs between them is how ties at one position are broken — you want the rules that keep a single author’s run of characters contiguous. It also tells you what to test. Shuffling op order proves convergence and proves nothing about readability; the case that matters is two authors inserting at the identical position, and the thing you judge is whether the output is still a sentence.
- 0central authority required
- any orderconverges to the same doc
- SECstrong eventual consistency
Back of the envelope
- position = causal id, not an offset
- never goes stale
- merge = union + deterministic sort
- no transform functions
- any two replicas, any op order
- same final document
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.
Why this piece earns its place
Presence is cheap per message and expensive as a stream, so it needs two things the document channel never does. First a rate. A caret moves on every mouse event, and in a busy room each participant is broadcasting to every other participant, so coalesce to the latest known position per peer and flush on a tick. That tick is a knob that costs at both ends: slow it and cursors teleport in visible jumps, speed it up and a large room spends more bandwidth saying where the carets are than moving the text anyone came to write. Second, an expiry. Because presence is never written down, nothing ever cleans it up, and the goodbye message is the one message you cannot rely on — a crashed tab, a shut laptop and a dropped connection all leave without sending it. Make absence the default: entries live for a short window and are kept alive by a heartbeat, so a ghost cursor fades on its own rather than needing to be deleted.
What the new pieces do
- Presenceservice
- A separate ephemeral channel for "who is online" and "where is their cursor" — high-frequency, disposable, and never part of the document's durable history.
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?
Editor A goes offline mid-edit. What should the client do?
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.
Blocking input on network state is exactly the failure mode users hate most — "my editor doesn't work because my train went through a tunnel." Local-first apps are built to keep working with zero network.
Manual re-merging reintroduces the exact conflict problem CRDTs exist to eliminate automatically. There's no need to punt this to the user.
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.
Why this piece earns its place
This step quietly assumes the local replica survives, and by default it does not. The edits most worth protecting are the unsent ones, and they are the only edits that exist in exactly one place: that tab. A refresh, a crash, or the operating system reclaiming a backgrounded tab takes the replica and the queue with it, and what is lost is precisely the stretch of typing the offline promise was made about. So persistence is not an optimisation for later — write each applied op into durable local storage on the same path that applies it to the in-memory replica, not on a timer afterwards, because a timer is a window during which the guarantee is simply false. It also collapses two code paths into one. On reload the client rebuilds from its own stored state and still holds its unsent queue, so returning from a crash and returning from a tunnel are the same flow — and the crash is the one you can actually test on demand.
- 0mslocal apply latency
- offline-safeby construction
- automerge on reconnect
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.
Why this piece earns its place
The pruning rule sounds absolute and is really a knob: you may drop a tombstone once every replica has seen it, and you decide who still counts as a replica. Keep counting a collaborator who opened the document once and never came back and the condition never holds again — one absent participant pins the history forever, nothing alarms, it simply grows. Drop them too eagerly and their queued ops reference ids that no longer exist — there is nothing left to merge them against, so those edits can never be restored to the place they were typed. So set a horizon and state it: replicas that have not checked in within it leave the version vector, and a client returning from beyond the horizon does not merge op by op — it loads the current snapshot and re-applies its unsent text as fresh insertions, keeping the words and giving up their exact causal placement. That horizon is the real bound on how long offline is allowed to last, and it belongs in the design rather than being discovered in production.
What the new pieces do
- Op Logdata
- The append-only log of every operation ever applied, including delete markers (tombstones) kept around so late-arriving concurrent ops can still merge correctly.
- Doc Storedata
- The durable, compacted document — periodic snapshots so a new client (or a restart) doesn't have to replay the entire op log from the beginning.
You did it
You just designed a collaborative editor.
Everything you assembled, in order
- A whole-document lock is correct but kills concurrency — real collaboration needs simultaneous writes.
- Naive position-based replay corrupts the document the instant two concurrent ops target the same spot.
- OT fixes this by transforming ops against a globally agreed order — but that requires a central sequencer in the hot path of every keystroke.
- CRDTs remove the central authority entirely: every character gets a causal, immutable id, so merges converge regardless of arrival order (strong eventual consistency).
- Presence/cursors are ephemeral and live outside the document's durable, merged history.
- Optimistic local apply + queued sync makes offline editing free — the CRDT merge on reconnect needs no manual conflict resolution.
- Tombstones from deletes accumulate forever unless you garbage-collect them once every replica has converged past them, plus periodic snapshots.