00. Design a Collaborative Editor¶
~20 min read · Level: advanced · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A collaborative editor lets many people edit one document at the same time and see each other's changes as they happen. This is the product behind Google Docs, and the same shape shows up in Notion, Etherpad, and the multiplayer canvas in Figma. Someone types a sentence, and within a fraction of a second everyone else looking at that document sees the same sentence appear, sees the author's cursor move, and can type into the same line without either person's text getting lost or scrambled. When two people edit the exact same spot at the exact same instant, both edits survive and every screen ends up showing identical text.
The problem looks like "sync a string across a few browsers," and that framing is a trap. What makes it a genuine system-design question is that edits are concurrent and unordered: two clients can each change the same position before either has heard about the other, network messages arrive out of order, and a client can keep typing while briefly offline. There is no natural single truth about "what the document says" until you impose one. The entire design is about imposing that truth cheaply enough that a keystroke still feels instant.
To keep the reasoning concrete, thread one scenario through the whole design: 50 people typing into the same paragraph of one document at the same time — a live meeting-notes doc during an all-hands, everyone piling edits into one block. Each editor types a few characters a second, all of it landing in the same few hundred bytes of text, and when the dust settles every one of the 50 screens must show byte-identical text in the identical order. That single paragraph, and that demand for identical convergence under a storm of concurrent edits, will test every decision below.
Functional requirements¶
- Real-time editing: multiple users edit one document concurrently; each edit propagates to all other open clients within ~100 ms.
- Convergence: no matter the order edits are created or delivered, every client converges to the identical document. No edit is silently lost or duplicated.
- Presence: show who else is in the document and where their cursor and selection are, updated live.
- Persistence: the document survives every crash and disconnect; a user can close the tab and reopen to the current state.
- Offline tolerance: a client that briefly loses its connection keeps editing locally and reconciles cleanly on reconnect.
De-scoped for this round, and worth saying out loud so the interviewer hears a choice rather than a gap: rich-formatting semantics (tables, images, comments threads) beyond plain-text-plus-simple-attributes, full document permissions and sharing, version-history UI, and spell-check or suggestions. These sit beside the core sync engine and do not change its architecture. Rich formatting is modeled as attributes on the same op stream, so it rides the machinery we build rather than replacing it.
Non-functional requirements¶
The single dominant constraint is conflict-free convergence under low-latency concurrent editing. Every other property bends around the demand that 50 concurrent editors end at identical text without any of them waiting on a lock.
- Convergence (correctness): the hard guarantee. All clients that have seen the same set of edits show identical text, regardless of arrival order. This is not eventual "close enough"; it is byte-for-byte identical.
- Latency: a local keystroke must render on the author's own screen in ~0 ms (never wait for the server), and reach other editors in well under 100 ms. Perceived collaboration breaks past ~150 ms.
- Availability on the edit path: an open document must keep accepting edits. A user typing a sentence should never see it rejected because a peer is also typing.
- Durability: an acknowledged edit is never lost, even if the server holding the live session crashes a millisecond later.
- Presence is disposable: cursor positions and "who's here" are ephemeral by design — losing them costs nothing and they must never be allowed to slow or block real edits.
Scale estimation¶
Take the threaded scenario as the stress case and build the numbers up from it. A person typing steadily produces roughly 5 edits/second (characters, backspaces, small selections). With 50 concurrent editors in one paragraph, that document's edit stream is 50 × 5 = 250 ops/second inbound. Each op is tiny — a document id, a revision number, a client id and sequence, and an action like insert "x" at position 137 — call it ~200 bytes on the wire including framing.
The number that shapes the architecture is not the inbound rate; it is the fan-out. Every one of those 250 ops/second must be delivered to the other 49 editors, so outbound traffic for this one document is 250 × 49 ≈ 12,250 messages/second, or 12,250 × 200 B ≈ 2.4 MB/s egress for a single paragraph being typed into. The reframing that drives everything: real-time editing is not an inbound-write problem; it is a fan-out-amplification problem — one small edit multiplied across every connected screen, exactly the way a viral link is one value multiplied across every reader.
Persistence math runs the other way. If we durably append every op, a heavily-edited document might accumulate 1,000,000 ops over its life. At ~100 bytes stored per op that is ~100 MB of log for one document — and replaying a million ops to open the doc, even at an optimistic 500,000 ops/second in memory, costs ~2 seconds, far too slow for a document open. So we cannot serve reads from the raw log. Materialize a snapshot of the full text periodically — say every 1,000 ops — and keep only the op-log tail since the last snapshot. Opening the doc then replays at most 1,000 ops on top of a snapshot, roughly 2 ms. The snapshot itself is small: a text document is on the order of ~100 KB, trivial next to the log it replaces.
Service-wide, assume ~2,000,000 concurrently connected editing clients at peak. At ~50,000 websocket connections per gateway box that is ~40 connection-holding servers. Documents, though, cannot be spread freely: all edits for one document must funnel to a single authority (below), so the unit of scale is the document session, not the connection.
API sketch¶
# Open a document: get a snapshot and the revision it is at.
GET /api/v1/docs/{doc_id}
200: { "content": "…", "rev": 84120, "collaborators": [...] }
# Live editing channel (WebSocket). Client subscribes from a known rev.
WS /api/v1/docs/{doc_id}/stream?from_rev=84120
→ client sends: { "type":"op", "client_id":"c7", "client_seq":31,
"base_rev":84120, "actions":[{"ins":"x","pos":137}] }
← server acks: { "type":"ack", "client_seq":31, "rev":84121 }
← server pushes: { "type":"op", "rev":84121, "client_id":"c3",
"actions":[...] } # a peer's transformed edit
↔ presence: { "type":"cursor", "client_id":"c3",
"pos":140, "sel_end":140, "name":"Ana", "color":"#39f" }
Solutioning¶
Start from the convergence demand and the shape of the answer appears. Because a keystroke must render locally in ~0 ms, the client cannot wait for the server before showing your own text — it applies every edit optimistically the instant you type, then reconciles with the server afterward. That immediately creates the core hazard: your client and mine both edited position 137 before either heard about the other, so our documents have diverged and must be pulled back together. Two families of algorithm do that reconciliation, and choosing between them is the defining decision of the whole system.
Operational Transformation (OT) routes every edit through a central server that imposes a single total order. When your op arrives stamped "I made this against revision 84120" but the server has already advanced to 84123, the server transforms your op against the three edits you hadn't seen — shifting its position so it still means what you intended — then assigns it revision 84124 and broadcasts it. Clients likewise transform incoming ops against their own not-yet-acknowledged edits. OT's ops are tiny (a position and a character), which is why it stores and ships cheaply, but the transformation functions are famously hard to get correct. CRDTs take the opposite bet: give every character a globally unique, densely-orderable identifier so that edits commute — merging two concurrent edits is deterministic set-union with no server and no transform, which is why CRDTs shine offline and peer-to-peer. The cost is metadata: every character carries an id and every delete leaves a tombstone, so a document's in-memory footprint can run several times its visible text.
The resolution for a server-hosted cloud editor like this one is OT with a central authority per document, and the reasoning is about where you can afford to put the hard problem. A cloud editor already has a server in the loop for persistence and fan-out, so the central-serializer requirement OT imposes is free — we needed the server anyway. Concentrating correctness in one server codebase means one place to test the transform logic, tiny ops on the wire and in the log, and a subtle theoretical simplification: because the server defines the single total order, the transform only has to satisfy the tractable TP1 property and never the notoriously unimplementable TP2 that true peer-to-peer OT demands. The memory hook: concurrent editing is not a locking problem; it's a convergence problem — you never block a keystroke waiting for a peer; you let everyone type freely and reconcile deterministically after the fact. CRDT is the right call when there is no reliable central server (local-first, true P2P, Figma's offline canvas); with a server present, OT's smaller footprint wins.
The 50-editor scenario is where this pays off. All 250 ops/second land on that document's single session server, which lays them into one total order, transforms each against whatever it missed, and broadcasts. When A, B, and C all insert at "position 5" in the same instant, the server's order plus a deterministic tie-break (by client id) decides that the characters land A, B, C — and every client, replaying that same total order through the same transform, produces the identical string. The serialization point is not a bottleneck to apologize for; it is the mechanism that makes convergence possible at all.
Two tradeoffs close the picture with numbers. First, latency versus convergence: applying edits optimistically buys 0 ms local feedback but means a client can be transiently wrong for one round-trip (~30–60 ms) until the server's order arrives and it re-transforms — an invisible, self-healing wrongness, which is the whole point. Second, snapshot versus op-log: keeping only the log makes every write a cheap append but makes a cold open cost ~2 s of replay over a million ops; snapshotting every 1,000 ops cuts that open to ~2 ms at the price of periodically materializing ~100 KB of text. We take both — durable op-log for truth and undo, periodic snapshot for fast reads — because they are cheap where the other is expensive. The following files take each decision down to components (HLD) and then to schemas, transform math, and edge cases (LLD).