Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

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 inserts∞ways to corrupt naive replay

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?

Editor Atyping "…quick…"Editor Btyping "…brown…"
New in this step: Editor A, Editor B.

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?

Editor Atyping "…quick…"Editor Btyping "…brown…"Relayops fan-out
New in this step: Relay.

A whole-document lock, one writer at a time. What breaks in practice?

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

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

  3. 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?

Editor Atyping "…quick…"Editor Btyping "…brown…"Relayops fan-out
New in this step: Editor B → Relay, Relay → Editor A.

Both ops target position 4, applied naively in receipt order. What's the result?

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

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

  3. 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?

Editor AEditor BRelayOT Sequencer
New in this step: OT Sequencer. · swipe to pan the diagram

OT needs a single agreed order of ops to transform against. Who decides that order?

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

  2. Running distributed consensus per keystroke is far too slow for something meant to feel instant. OT was designed specifically to avoid this cost.

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

Editor AEditor BRelayOT Sequencer
The system as it stands at this step. · swipe to pan the diagram

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.

Editor AEditor BRelayOT Sequencer
The system as it stands at this step. · swipe to pan the diagram

How do you make merges conflict-free without a central sequencer at all?

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

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

  3. 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?

Editor AEditor BRelayPresence
New in this step: Presence. · swipe to pan the diagram

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 AEditor BRelayOT SequencerPresence
The system as it stands at this step. · swipe to pan the diagram

Editor A goes offline mid-edit. What should the client do?

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

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

  3. 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?

Presencecursors, selectionOp Logops + tombstonesDoc Storecompacted snapshot
New in this step: Op Log, Doc Store.

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.

Editor AEditor BRelayOT SequencerPresenceOp LogDoc Store
The finished design, end to end. · swipe to pan the diagram

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.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why can't you just keep using raw position indices and "be more careful" about ordering?

    Because position is fundamentally relative to a specific document state, and concurrent editors by definition don't share a state at the moment they act — that's what "concurrent" means. Any fix that keeps raw offsets has to either transform every op against every other op it wasn't aware of (OT's approach, which needs an agreed order) or accept corruption. There's no amount of "being careful" that removes the need for one of those two strategies.

  2. If CRDTs are strictly better, why did Google Docs ship with OT?

    Timing and maturity: Google Docs shipped in 2006, years before practical sequence CRDTs (RGA, Peritext) were published and proven. OT was the known, working answer at the time, and Google invested heavily in getting its transform functions correct for rich text. CRDTs are the newer, generally simpler-to-reason-about answer that later editors (Figma, Notion, and CRDT libraries like Yjs/Automerge) adopted once the algorithms matured — but OT at Google Docs' scale is a proof that a well-executed central-sequencer design can absolutely work in production.

  3. Does removing the central sequencer mean CRDTs have no server at all?

    No — you still typically want a relay/server for practical reasons: NAT traversal (peers usually can't connect directly), persistence (someone needs to store the document when nobody's online), and access control (who's allowed to open this doc). What the CRDT removes is the requirement that the server be an ORDERING AUTHORITY that every op must be validated against before it's considered final. The server becomes a dumb relay and a backup, not a bottleneck.

  4. How do CRDTs handle rich-text formatting (bold, headings, nested lists), not just plain characters?

    The same causal-id idea extends to formatting spans and structural moves, but it's genuinely harder than plain-text insert/delete — this is what the Peritext research paper specifically tackles: how do you represent "make characters 10-20 bold" as a CRDT operation such that concurrent formatting changes (one person bolds a range, another deletes part of it) still merge sensibly? The general answer is representing formatting as its own CRDT layer (a set of marks with start/end anchors tied to character ids) rather than baking it into plain-text positions.

  5. What actually breaks if you skip tombstone garbage collection forever?

    The op log and in-memory structure grow monotonically with total edit volume, not with document size — a document that's been edited 500,000 times over two years can carry megabytes of tombstone metadata for a document that visually looks like one page. That means slower load times (more to replay/parse), more memory per open document, and larger sync payloads for new collaborators joining. None of it corrupts correctness, but left unmanaged it's a quiet, compounding performance problem exactly like never vacuuming a database table.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A whole-document lock for editing is mainly bad because…

    A lock turns simultaneous collaborators into a queue and makes the lock-holder a bottleneck/SPOF.

  2. Naive position-based ops corrupt a document because…

    The moment a concurrent op lands first, every other pending op's raw index no longer points at what it meant to.

  3. Operational Transformation guarantees convergence by…

    OT's transform functions rewrite ops relative to a single globally agreed order, typically assigned by a central server.

  4. CRDTs remove the need for a central sequencer by…

    A sequence CRDT's positions never go stale, so any two replicas converge to the same document regardless of op arrival order.

  5. Why do CRDT documents need tombstone garbage collection?

    A delete can't remove a character outright because a not-yet-seen concurrent op might reference it; tombstones must be pruned once every replica has converged past them.

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.

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.

The qualities that shape everything

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 writes over 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 ops over 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 sync over 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.

The answer, out loud

What a strong answer to “Design a Collaborative Editor” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    What this actually has to guarantee

    Let me scope it first. We’re designing shared mutable state that several people change at the same instant, and the hard requirement is convergence: whatever order edits arrive in, everyone ends up looking at the identical document and nobody’s words quietly vanish. Around that: live cursors, editing that still works with no network, and durable history so a new reader doesn’t replay from the beginning. I’ll build both real answers in order.

  2. 4–8 min

    Start with a lock, then give it up

    I’ll start from the simplest correct thing so we agree on what we’re giving up. One lock on the document, one writer at a time. It genuinely cannot corrupt anything, and it’s useless, because collaborative means simultaneous — if you have to wait for me to finish a paragraph before you can type a character, we’ve built a check-out queue. And whoever holds that lock is a bottleneck and a single point of failure for everyone else. So I’m dropping locking entirely and taking on the problem it was hiding.

    Built in step 1: One writer at a time
  3. 8–14 min

    The problem the lock was hiding

    Here it is. I insert a word at position four; at the same moment you insert a different word at position four, and neither of us has seen the other. Both ops reach the relay and get applied where they said. The result is garbage — my insert shifted the text under yours, so your position four no longer points at what you meant. That’s the whole difficulty in one sentence: a position is only meaningful against the document state it was created against, and concurrent editors by definition don’t share a state. Every real solution is an answer to exactly that.

    Built in step 2: What happens if you just… let them both write?
  4. 14–21 min

    Operational transformation

    The first answer, and the one Google Docs shipped, is operational transformation. Keep the position-based ops, but before applying a remote op, transform it against every local op it didn’t know about, shifting its position to account for what already landed. That only works if everyone transforms against the same sequence, so a central sequencer stamps each op with the next version number as it arrives and broadcasts in that order. Clients hold their unacknowledged ops in a buffer, transform in both directions against it, and everyone applies the same logical sequence.

    Built in step 3: Transform ops against each other
  5. 21–26 min

    What OT costs

    The price is worth saying out loud. Every keystroke now depends on one server to assign order, so if the sequencer is gone, clients that can still reach each other perfectly well can’t finalise anything. And the transform functions have to be right for every pair of operation types — insert against delete, formatting against a move, nested structure — which for rich text is a research problem. A single wrong case doesn’t crash; it leaves two documents that stopped being the same document.

    Built in step 4: The sequencer is a bottleneck — and the math is brutal
  6. 26–33 min

    Change the data structure instead

    So I’d rather change the data structure than keep repairing the ops. Instead of an offset, give every character a permanent identity — who inserted it, a logical clock, and which character it went after. Positions built that way never go stale, so merging two replicas is a union of the ops they know, sorted deterministically. Any two replicas that have seen the same edits hold the same document regardless of arrival order, and no server had to decide anything. That’s strong eventual consistency. I still want a server — for relaying, for persistence, for who’s allowed in — just not as an authority.

    Built in step 5: Make the merge itself conflict-free
  7. 33–37 min

    Cursors don’t belong in the document

    Cursors I’d deliberately keep out of that structure. Where someone’s caret is right now is high frequency, worthless in an hour, and gone the moment the tab closes, so it rides a separate ephemeral channel through the relay and never touches the op log the document’s correctness depends on. Putting it in the document would mean permanently merging noise nobody will ever read back.

    Built in step 6: Not everything belongs in the document
  8. 37–41 min

    Offline comes almost for free

    Offline then falls out of the CRDT almost for free, which is the second reason I picked it. Because merging needs no server round trip to be valid, the client applies every edit to its own replica immediately — typing is never waiting on the network — and queues whatever it hasn’t sent. On reconnect it exchanges that queue and merges, with no conflict dialog, because convergence was guaranteed by construction instead of resolved after the fact.

    Built in step 7: Keep typing when the network is gone
  9. 41–44 min

    The sharp edge is deletion

    The sharp edge is deletion. A CRDT can’t actually remove a character, because an op I haven’t seen yet might be anchored to it, so a delete leaves a tombstone. Edit a document for a year and the metadata for everything ever deleted can outweigh what’s on the page — slow loads and fat syncs rather than anything visibly broken. So I’d garbage-collect once every replica has confirmed it has seen a tombstone, and snapshot periodically so a new client loads a compacted document instead of replaying the log from zero.

    Built in step 8: CRDTs never truly delete — tombstones grow forever
  10. 44–45 min

    Where I’d go next

    The trade I’ve made is a heavier data structure in exchange for never needing a central authority, and I think that’s the right side of it — availability and offline both fall out of the same property. With more time I’d push on rich text — formatting and moved blocks as their own layer of marks anchored to character ids — and I’d pin down the compaction horizon: exactly how long a replica can be away before we stop waiting for it.

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
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
NextDesign a Notification System Closest system design to this one

Explore the topic

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

More System Designs