System Design

Design a Collaborative Editor

Step 1 / 9

Learn system design by building a real-time collaborative document editor like Google Docs, Notion or Figma step by step.

The numbers to beat0solutions so far2concurrent insertsways to corrupt naive replay

Deep cut · 12:32

Now watch the sentence break, and get rebuilt

The diagram shows the finished machine. The video starts from two people typing into the same gap at the same instant, watches both screens end up with different sentences — no error, no warning — and then builds the fix one part at a time, each bought by a number the design has to survive.

  • Watch it break: a sequencer, the transform that rewrites a stale position, and the exact requirement that deletes the sequencer again.
  • Then watch it hold: nametags instead of positions, merge by union, presence off the durable path, an offline queue, and tombstones that get pruned.

In the interview room

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

Functional requirements

What it must do — agree on these before drawing a single box.

  • Concurrent edits: N people type into the same document at once, no one’s work silently lost.
  • Converge: every replica ends at the identical final document, whatever order edits arrive.
  • Presence: show each collaborator’s live cursor and selection.
  • Offline-first: keep editing with no network and auto-merge on reconnect.
  • Persist: a durable op log plus periodic snapshots so a new client doesn’t replay from time zero.

Non-functional requirements

The qualities that shape the whole design — each one names the mechanism that buys it.

True concurrent writes, not a queue
Drop the whole-document lock — a lock serializes every collaborator through one holder and makes that holder a bottleneck and single point of failure.
Concurrent ops converge without corruption
OT transforms each op against every concurrent op it wasn’t aware of, using a single globally-agreed order stamped by a central sequencer.
Merge with no central authority
A sequence CRDT gives every character an immutable causal id, so any two replicas merge by union and converge in any order — strong eventual consistency, no sequencer.
Cursors don’t pollute durable history
Presence and selection ride a separate ephemeral channel, never persisted, versioned, or added to the document’s op log.
Zero-latency and offline editing
Apply every edit optimistically to the local replica and queue unsent ops; the CRDT merges automatically on reconnect with no conflict dialog.
History stays bounded
Garbage-collect tombstones via version vectors once every replica has converged past them, plus periodic snapshots so op-log replay stays bounded.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

True concurrent writesover a whole-document lock

A document-wide lock serializes every collaborator through one holder and makes that holder a bottleneck and single point of failure — a check-out queue, not collaboration. Real editors need everyone typing at once.

Transforming or CRDT-merging opsover naive position-based replay

An op’s position is only valid against the document state when it was created; the instant a concurrent op lands first, every pending op’s raw index is stale and applying it corrupts the text. You must transform against concurrent ops or make positions never go stale.

A central sequencer (OT)over each peer ordering ops independently

If peers ordered ops locally they could transform differently and diverge — exactly what OT prevents — and per-keystroke consensus is far too slow to feel instant. One server-stamped global order lets every client transform against the same sequence and converge.

A CRDT (causal ids)over a replicated central sequencer

A replicated sequencer improves availability but keeps the dependency: ops still need one agreed order before they’re valid. Giving every character an immutable causal id makes merge a union that converges in any order — strong eventual consistency with no central authority.

Optimistic local apply + queued syncover blocking typing until reconnect

Because CRDT merges are order-independent and need no server round trip, the client applies edits to its local replica instantly and queues unsent ops, merging automatically on reconnect with no conflict dialog. Blocking input on network state is the failure mode users hate most.

What this teaches

Learn system design by building a real-time collaborative document editor like Google Docs, Notion or Figma step by step. An interactive guide covering why locking kills concurrency, why naive concurrent writes corrupt a document, Operational Transformation and its central-sequencer bottleneck, CRDTs and why they merge without coordination, presence/cursors as ephemeral state, offline-first local edits, and tombstone growth — plus the unhappy paths.

Key takeaways

  • 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.

Concepts covered

  • What is a collaborative editor?
  • One writer at a time
  • What happens if you just… let them both write?
  • Transform ops against each other
  • The sequencer is a bottleneck — and the math is brutal
  • Make the merge itself conflict-free
  • Not everything belongs in the document
  • Keep typing when the network is gone
  • CRDTs never truly delete — tombstones grow forever

Design a Collaborative Editor — read the full walkthrough as text

the same steps, decisions & trade-offs, for reading, reference & 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 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.
built so two people can type into the same sentence and both survive — make the calls, kill the sequencer, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs