Design a Collaborative Editor
Let many people edit one document at once: merging edits with OT or CRDTs, one server per document, an operation log with snapshots, and cursors.
On this page
Three people have the same document open. Ana fixes a typo while Ben adds a sentence above it and Chen watches both cursors move. Each types into their own copy with no delay, and a moment later every copy shows the same text. Nobody presses Save and no edit is lost.
Interviewers ask this because the boxes are simple and the core problem is not: two edits made at the same moment against the same text must merge into one result, on every screen, whatever order the network delivers them in. The steps follow A Framework for Any Design; try each "Your turn" box before you read on.
Clarify the problem
Design a collaborative document editor.
Plain text or rich text? And do we need comments and version history?
Rich text, but reason about plain text. Version history, yes. Comments are out.
How many people edit one document at once, and how big is the whole thing?
Usually one to three, sometimes up to 100 in a meeting. About 20 million daily users; at peak 2 million have a document open.
Then the core requirement is convergence: everyone ends with the same text and no keystrokes vanish. I'll aim for zero delay when typing, other people's edits within about 200 ms in one region, and no acknowledged edit ever lost. I'll handle short offline periods.
- Functional: open a document; type and see your change instantly; see other people's changes and cursors live; keep working offline and sync on reconnect; browse version history.
- Non-functional: all copies converge; remote edits appear within about 200 ms in one region; an acknowledged edit is never lost; up to 100 editors per document; a document opens in under a second.
- Out of scope: comments, search, sharing screens, rich-text layout.
Estimate the scale
Editors do not send a message per keystroke. The client batches the last 100 ms or so of typing into one operation, such as "insert the at position 4". Assume an open document sends one operation every 2 seconds on average, counting pauses.
How many operations per second reach the servers at peak?
- 2 million people have a document open at peak
- Each sends one operation every 2 seconds on average
Show the working
- 2,000,000 ÷ 2 s = 1,000,000 operations a second
Estimate 1 million/s (anything from 300 thousand/s to 3 million/s is a good answer)
Each one must be ordered against the others on the same document, which is what the design is about.
- Daily volume: with a document open about an hour a day, 3,600 ÷ 2 ≈ 2,000 operations per user, so 20 million users × 2,000 = 40 billion, an average of 40 × 109 ÷ 86,400 ≈ 460,000 a second. The peak of 1 million is about twice that.
- Fan-out: with 2 editors per open document, there are 1 million open documents. Each operation is acknowledged to its author and sent to one other person: 2 million outgoing messages a second.
- Connections: 2 million WebSockets. At an assumed 50,000 per server (load test it), 40 servers.
- Snapshots: assume 1 billion documents of about 50 KB: 50 TB for the latest copy of each.
How much operation log is written per day?
- 40 billion operations a day
- About 100 bytes per operation (position, text, author, revision, time)
Show the working
- 40 × 109 × 100 B = 4 × 1012 B = 4 TB a day
- Kept for 30 days: 120 TB, or 240 TB on a leader and its replica
Estimate 4 TB (anything from 1 TB to 15 TB is a good answer)
The log grows far faster than the documents, so it must be folded into snapshots.
Write the messages a client and server exchange while editing. What must each operation carry so the server knows what it was based on?
Compare with a model answer
An HTTP call loads the document at a revision; then a WebSocket. Each operation carries the revision it was typed against and a client sequence number. The server acks with the operation's final revision and sends everyone else's operations with theirs.
Define the API
GET /docs/{id}
200 { "rev": 1042, "content": "...", "title": "Q3 plan" }
403 Forbidden no access 404 Not Found
WSS /docs/{id}/live?since=1042
client -> server
{ "type": "op", "base_rev": 1042, "client": "c7", "seq": 17,
"ops": [ { "insert": "the ", "at": 4 } ] }
{ "type": "cursor", "rev": 1043, "at": 88 }
server -> client
{ "type": "ack", "seq": 17, "rev": 1043 }
{ "type": "op", "rev": 1044, "author": "ben", "ops": [...] }
{ "type": "cursors", "users": [ { "user": "ben", "rev": 1044, "at": 12 } ] }
{ "type": "reload" } since is older than the kept log; fetch a snapshotThe decision worth arguing: each client keeps at most one operation in flight. Until seq 17 is acknowledged, new typing is buffered and sent as one operation after the ack. The server then only adjusts an incoming operation against what was committed since its base_rev. The pair (client, seq) makes a resend harmless: the server remembers the last seq applied per client and drops duplicates.
What do you store so a document opens quickly, can be replayed for history, and can be caught up after a reconnect?
Compare with a model answer
An append-only log of operations keyed by document and revision; periodic snapshots of the full text so opening does not replay everything; and a small metadata record per document. Presence is not stored at all.
Model the data
documents -- metadata, relational
doc_id BIGINT PRIMARY KEY
owner_id BIGINT
title VARCHAR(200)
snapshot_rev BIGINT -- newest snapshot
updated_at TIMESTAMP
doc_ops -- about 100 B a row
doc_id BIGINT
rev BIGINT -- 1, 2, 3 ... per document, no gaps
author_id BIGINT
client_id VARCHAR(16)
client_seq INT
op BYTES -- the transformed operation
created_at TIMESTAMP
PRIMARY KEY (doc_id, rev)
snapshots -- blob store, key "docs/{doc_id}/{rev}", about 50 KBAccess patterns: append the next revision of document 42, read its operations after revision 1000, fetch its newest snapshot. All stay inside one document, so partition everything by doc_id.
For doc_ops, use a store with one leader per shard and a unique primary key, such as a sharded relational database. "Insert revision 1043" then fails cheaply if 1043 exists, which the deep dives use to stop two servers writing the same revision. At 1 million inserts a second and an assumed 20,000 per shard, that is 50 shards, each with a synchronous replica so an acknowledged operation survives losing the leader. A leaderless store fits the range reads too, but there "insert if absent" costs a consensus round on every operation. Snapshots are write-once blobs, so they go to blob storage.
Sketch the boxes. Where do the WebSockets end, which server decides the order of operations for document 42, and how do all its editors reach that server?
Compare with a model answer
Gateways hold the sockets and forward each document's messages to the one document server that owns it, chosen by consistent hashing on the document id. That server orders, transforms, logs and broadcasts operations. A compactor folds old operations into snapshots.
High-level design
Every operation on a document must be put in one order, and the cheapest place to do that is the memory of one process. So each open document has exactly one owner: a document server holding its text, recent operations and the people in it.
- server rev
- 1042
1Document 42 is at revision 1042 and hashes to server B, so the gateways send Ana's and Ben's messages there.
- server rev
- 1042
- Ana in flight
- seq 17
- Ben in flight
- seq 9
2Ana and Ben type in the same 100 ms. Each sees their own change at once and sends it with
base_rev1042.- server rev
- 1043
- Ana in flight
- seq 17
- Ben in flight
- seq 9
3Ben's arrives first. It is based on the latest revision, so B logs it unchanged as 1043, on the shard leader and its replica.
- server rev
- 1043
- Ana in flight
- seq 17
- Ben in flight
- none
4Only now does B ack Ben and send the operation to Ana. An ack means the edit is durable.
- server rev
- 1044
- Ana in flight
- none
- Ben in flight
- none
5Ana's arrives, still based on 1042. B transforms it against 1043 (shown in the next section), logs it as 1044, acks Ana and sends it to Ben. All copies match.
- server rev
- 1044
- Ben in flight
- seq 10
6Ben types again (seq 10). Server B's machine hangs. Ben has no ack, so his client keeps the operation.
- server rev
- 1044
- Ben in flight
- seq 10
7B's heartbeats stop, so the membership store drops it and the ring maps document 42 to C. Clients reconnect after a random wait with
since=1044.- server rev
- 1045
- Ben in flight
- seq 10
8C loads the snapshot at 1000 plus the 44 operations after it, which also tell it each client's last seq. Ben resends seq 10; it is new, so C logs it as 1045.
- server rev
- 1045
9B was only paused. It wakes, still thinks it owns document 42, and tries to log a late operation as 1045. The insert fails: 1045 exists.
- server rev
- 1045
- Ben in flight
- none
10The failure means another server wrote 1045. B checks the ring, sees C owns document 42, drops it and closes its sockets; those clients retry on C. The log never forks.
Deep dives
Merging concurrent edits: OT or CRDT
The document is dog. Ana adds s at the end: insert at 3. At the same moment Ben adds my at the start: insert at 0. If each side applies the other's operation as sent, Ana gets my dogs, but Ben applies "insert s at 3" to my dog and gets my sdog. The copies have diverged.
Operational transformation (OT) fixes the position before applying. For two inserts: if the other insert landed before yours, shift yours right by its length; at the same position, the one the server committed first goes left. Deletes need matching rules (overlapping deletes shrink each other), and that is where implementations get subtle.
transform(mine, theirs): if theirs.at < mine.at: mine.at += len(theirs.text) if theirs.at == mine.at and theirs was committed first: mine.at += len(theirs.text) return mine- Ana0d1o2gserver0d1o2gBen0d1o2g
- rev
- 7
1Everyone has
dogat revision 7. - Ana0d1o2g3sserver0d1o2gBen0m1y23d4o5g
- rev
- 7
- Ana sends
- ins 's' at 3
- Ben sends
- ins 'my ' at 0
2Both type at once. Each sends an insert based on revision 7.
- Ana0d1o2g3sserver0m1y23d4o5gBen0m1y23d4o5g
- rev
- 8
3Ben's arrives first and is based on the latest revision, so it is stored unchanged as revision 8.
- Ana0d1o2g3sserver0m1y23d4o5g6sBen0m1y23d4o5g
- rev
- 9
- stored as rev 9
- ins 's' at 6
4Ana's arrives, based on 7. Revision 8 inserted 3 characters at 0, before 3, so the server shifts Ana's insert to 3 + 3 = 6.
- Ana0m1y23d4o5g6sserver0m1y23d4o5g6sBen0m1y23d4o5g
- rev
- 9
- Ana's pending
- now at 6
5Ana's client gets revision 8 while her insert is unacknowledged. Her pending insert at 3 is after 0, so Ben's op applies as sent, and her pending op moves to 6.
- Ana0m1y23d4o5g6sserver0m1y23d4o5g6sBen0m1y23d4o5g6s
- rev
- 9
- converged
- yes
6Ben receives revision 9, already transformed: insert at 6. Ana gets her ack. Three copies, one text:
my dogs.
The central server is what keeps OT manageable. Because it picks one order, the transform only has to guarantee that A then B-transformed gives the same text as B then A-transformed. Without a server, peers see operations in different orders and the transform must meet a much harder second condition, which several published peer-to-peer OT algorithms were later shown to break.
A CRDT (conflict-free replicated data type) avoids positions. Every character gets a permanent unique id, such as (ben, 1), and an insert says "after the character with id X". Ana's s goes after the id of g; Ben's m goes at the start. Ids never shift, so concurrent operations can be applied in either order, a repeated one changes nothing, and every copy ends at my dogs. Two inserts after the same character are ordered by comparing their ids. A deleted character stays as a hidden tombstone, because a later insert may point at it.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| OT with a central server Usual default | Small operations and a plain-text document. One place for order, access checks and history. | Needs an owner online per document. Rich-text transforms are hard to get right; long offline sessions transform against many operations. | A hosted product where every document already has a server. My choice here. |
| CRDT | No ordering server: edits merge in any order, so peer-to-peer and long offline work come naturally. | Ids on every character and tombstones add metadata until compacted. Concurrent inserts can interleave in surprising ways. | Offline-first or peer-to-peer apps, or several regions writing one document. |
My pick is OT with a central server: the design needs one owner per document anyway, for order, presence and access checks, so OT's main cost is already paid. "Offline for days" or "no server in the middle" would flip it to a CRDT, with a server kept only to store and relay operations.
One server per document
The gateways hash the document id onto a consistent-hash ring of document servers. Ring membership lives in a small strongly consistent store that every gateway caches and watches. With hash mod N, adding a 41st server would remap almost every document. With a ring, about 1/41 move: 1,000,000 ÷ 41 ≈ 24,000 documents, each reloaded from its snapshot.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Any server, order in the database | No ownership; any server takes any edit and relays it through pub/sub. | Every operation needs a database round trip to claim a revision, and racing claims retry. | Low edit rates, such as a shared to-do list. |
| Owner by consistent hashing Usual default | Ordering happens in one process's memory, with no lookup per connection. | Membership changes move documents; you cannot move one hot document on purpose. | Many small documents. The usual answer. |
| Owner directory | You can place a busy document on a quiet server. | A lookup per open, and the directory must stay highly available. | Very uneven load. |
Ownership can briefly overlap: a server paused by a long garbage-collection run may wake still believing it owns a document. A lease makes this rare: an owner stops serving when its lease lapses, before the store reassigns the document. The log as a fence makes it harmless. An owner only inserts the revision after the last one it has seen, and each revision can be written once, so two servers can never both commit 1045. The loser of that race re-reads the log, and steps down if the ring names another owner. A new owner learns each client's last seq from the logged operations, so resends stay harmless. Consensus and leader election explains why a lease alone is not enough.
Storing operations and compacting them
The compactor writes a snapshot every 1,000 operations, so nobody replays a busy document from revision 1. Opening a document reads one 50 KB snapshot and at most 999 operations, about 150 KB. Keep fine-grained operations for 30 days (120 TB) for catch-up and recent history, then delete them. Do not keep every snapshot: that would be 40 million a day, 2 TB. Keep the newest, plus one per editing session for version history. A snapshot is a cache of the log, so a bad one is rebuilt from the previous snapshot and the operations.
Cursors, reconnects and offline work
Cursors are not operations. Nobody needs their history and losing one is harmless, so they stay in the owner's memory and are never logged. A cursor is sent with its revision; when Ben inserts 3 characters before Ana's cursor, it shifts right by 3 with the same transform used for text. Cursor traffic is what hurts big documents: 100 editors sending their cursor 10 times a second to 99 others is 100 × 10 × 99 = 99,000 messages a second. Instead the owner sends each client one combined update every 500 ms: 100 × 2 = 200 messages a second.
Reconnecting reuses the edit path. The client keeps its last acknowledged revision and its unacknowledged operations, reconnects with since=1044, transforms its pending operations against what it receives, and resends them with their original (client, seq), so nothing is applied twice.
Offline editing is a long reconnect: the offline edits are sent as one operation with the old base_rev and transformed against everything since. If that revision is older than the 30-day log, the server answers reload; the client fetches a snapshot and does a three-way text merge, or saves its version as a copy.
Bottlenecks and failures
At 10x (10 million operations a second), gateways and document servers scale by adding machines, and the ring moves a small share of documents each time. The log grows to about 500 shards. The real limit is one document, since one owner serves all its editors. At 100 editors, 100 × 0.5 = 50 operations a second sent to 99 others is about 5,000 messages a second: easy. A document read by 10,000 people needs a read-only stream for viewers through a separate fan-out tier.
| When this dies | What happens | What you do |
|---|---|---|
| A document server | With 40 servers, its 1,000,000 ÷ 40 = 25,000 documents lose their owner. Typing continues locally. | The ring reassigns them; their 50,000 editors reconnect with jitter (over 10 seconds, 5,000 a second) and resend. |
| A gateway | Its sockets drop. | Clients reconnect through another gateway and catch up; owners are unchanged. |
| A log shard leader | Its documents cannot commit; edits wait unacknowledged. | The synchronous replica takes over with every acked operation; clients resend the rest. |
| The membership store | No ownership changes. | Gateways route on their cached ring; run it on 3 or 5 nodes. |
| The compactor | No new snapshots; opening gets slowly slower. | Nothing is lost; restart it. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Uses WebSockets and saves the document, notices that concurrent saves overwrite each other, and with prompting suggests locking or merging. |
| Mid | Sends operations, explains OT or CRDTs with an example, and keeps an operation log with snapshots. |
| Senior | Ties the merge choice to the architecture, routes editors with consistent hashing, fences stale owners, keeps cursors out of the log, and says what offline work and 100-editor documents cost. |
Everyone says CRDTs are the modern answer. Why OT?
I already have one owner per document for ordering, presence and access checks. With one server picking the order, OT only has to make pairs of operations agree, and the stored document stays plain text. A CRDT would remove that server, but I'd pay for ids on every character and tombstones. If offline for days or peer-to-peer were requirements, I'd switch.
Follow-up questions
How does undo work with several editors?
Undo only undoes your own edits. The client keeps the inverse of each of its operations, transforms it against everything committed since, and sends it as a new operation.
How do you add formatting such as bold?
Add operations over ranges, such as "bold from 10 to 18". The same transform shifts the range ends when text is inserted or deleted before or inside them.
Someone loses access while editing. What happens?
The metadata service tells the owner, which drops that user's socket and rejects their pending operations. Every operation passes through the owner, so there is one place to enforce it.
How would you run this in several regions?
Each document's owner lives in one region. Distant editors still type with zero delay, but see others' edits one cross-region round trip later. If two regions must accept writes for one document, that is the case for a CRDT.
How do you show last Tuesday's version?
Take the nearest snapshot before that time and, within 30 days, apply operations up to the chosen moment. Older versions are limited to the kept snapshots.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".