Design Google Docs (Collaborative Editing)
Many people type in one document at once. Local echo keeps typing instant, a server orders the edits, and OT or a CRDT merges the concurrent ones.
- 2M ops/sfrom 1M concurrent editors
- ~100-200 msfor others to see an edit
- ~500 opsworst-case replay after a crash
Show what to say
“I'd show each edit locally at once and send it, with the revision it was based on, to a session server that owns the document. The server transforms it past edits the sender missed (operational transformation), appends it to an operation log, assigns the next revision and broadcasts it. Snapshots every few hundred edits make recovery fast. A CRDT is the alternative for offline-first.”
Trade-offs
- OT vs CRDT: OT is compact with a central server but subtle to get right; CRDTs work offline and peer-to-peer but carry ids and tombstones.
- Local echo vs wait for ack: typing feels instant, but edits must be transformed and reconciled when the server answers.
- One server per document vs sharing a document: a single order is simple, but one hot document is bounded by one machine.
Bring it up before they ask
Unacknowledged edits after a crash: clients resend by operation id, and the server ignores repeats, so nothing is lost or applied twice.
Show every edit locally at once, and send it to a session server that puts all edits in one order. Concurrent edits are fixed by (rewrite an edit to fit the ones it missed) or by a (give every character a permanent id). The operation log is the source of truth.
Functional
- Many people edit one document at the same time
- Everyone sees the same text in the end
- See other people's cursors and names
- Version history and restore
- Keep working offline and merge on return
Non-functional
- Your own typing appears instantly (local echo)
- Others' edits arrive in about 100 to 200 ms
- No accepted edit is ever lost
- 1M concurrent editors at peak
- A document survives a server crash
Out of scope
- Rich-text formatting rules and tables
- Comments, suggestions and permissions UI
- Search across documents
Clarify and estimate
Ask first:
Read more · 180 words
- How many editors per document? Three is typical; hundreds is a special case.
- Plain text or rich text? Rich text adds attributes; the idea is the same.
- Offline? Long offline edits favour .
- Server in the loop? A central server makes ordering simple.
Capacity estimate
| Quantity | Calculation | Result |
|---|---|---|
| Edit rate | 1M editors × 2 batched ops/s | 2M ops/s |
| Ingest | 2M/s × 50 B | 100 MB/s |
| Active documents | 1M editors ÷ 3 per document | ≈ 333K |
| Session servers | 333K ÷ 5,000 documents per server | ≈ 67 |
| Operation log | 10M editors × 1 h × 2 ops/s × 50 B | ≈ 3.6 TB/day |
| Replay after a crash | snapshot every 500 ops | at most 500 ops |
The key insight: traffic is high overall but tiny per document. One server can own a document and order its edits. by document id.
API and data model
API. messages, one socket per open document.
Read more · 95 words
| Direction | Message | Payload |
|---|---|---|
| Client to server | join | { docId, lastRev } |
| Client to server | op | { opId, baseRev, op } |
| Server to client | ack | { opId, rev } |
| Server to client | remote | { rev, op, by } |
| Both | cursor | { pos, name, color }, not stored |
REST: POST /docs, GET /docs/:id (latest snapshot and revision), GET /docs/:id/revisions.
Data model
Large snapshots live in ; the table keeps a pointer. Cursors and presence are never stored.
CREATE TABLE ops (
doc_id bigint,
rev int, -- 1, 2, 3 ... per document, assigned by the session server
op_id uuid, -- from the client, so a retry is recognised
author bigint,
op jsonb,
PRIMARY KEY (doc_id, rev),
UNIQUE (doc_id, op_id)
);
CREATE TABLE snapshots (
doc_id bigint, rev int, content text, -- the text after `rev` operations
PRIMARY KEY (doc_id, rev)
);Architecture
Edits take the path editor, gateway, session server, log.
Read more · 25 words
The router sends all editors of a document to one session server. Play Type a character, Concurrent edits, Reconnect after offline and Session server crashes.
Tap any box to see what it is and what it talks to.
- Client
- API gateway
- Service
- Pub/Sub
- Database
- Object storage
Text description
- A types 's'. It shows on A's screen immediately. The editor also sends insert('s', pos 3) with baseRev 12.
- The gateway asks the router which session server owns this document.
- The edit reaches that server. Nothing else has been accepted since revision 12, so there is nothing to transform against.
- It becomes revision 13 and is appended to the operation log. Only now is it durable.
- The server acknowledges revision 13 to A, and broadcasts the operation to B.
- B applies the operation and sees the 's' appear, about 100 ms after A typed it.
Operational transform and CRDTs
Operational transform. Two people edit 'cat' at once: A appends 's', B deletes 'c'.
Read more · 218 words
Each edit is a position in the text, and positions go stale when something else changes. (OT) fixes this: the server rewrites the later edit so it means the same thing after the earlier one.
Each client sends an edit tagged with the revision it started from. The server transforms it past everything accepted since, assigns the next revision and broadcasts it. Each client keeps one edit in flight and transforms incoming edits past its own unacknowledged ones. Everyone converges, and typing never waits.
, the alternative. A CRDT avoids rewriting. Every character gets a permanent id. An insert says "after character X", and a delete only marks a character dead (a ). Edits merge in any order, so no central server is needed and offline editing is natural.
The price is memory: ids and tombstones stay until compaction. Compare both on the same example, then pick.
- Client
- Service
Text description
- Editor A → Server: insert('s', pos 3)
- Editor B → Server: delete(pos 0)
- Server replies to Editor B: A's insert, shifted to pos 2
- Server replies to Editor A: B's delete(pos 0)
Presence, storage and history
Presence and cursors. A cursor changes ten times a second and is worthless a second later.
Read more · 101 words
Send it over the same socket but through a lightweight channel, never write it to the log, and drop it when the socket closes. Throttle to about 10 updates a second and fade idle cursors.
Storage and history. Append every accepted operation to the log. Every few hundred operations write a snapshot of the whole text. Loading a document means the latest snapshot plus the tail of the log, at most about 500 operations.
Version history is the log: pick a revision, replay up to it. Old operations can be merged into coarser "named versions" after a month to save space.
Step-by-step answer
Order: clarify, estimate, pick an owner per document, resolve conflicts, then durability and scale.
Read more · 228 words
Start with the user experience
Typing must feel instant, so apply edits locally first and reconcile later. That is the whole reason this is hard.
Route a document to one server
Hash the document id to a session server. All its editors connect there through gateways, so one process can order edits.
Number every accepted edit
Each edit carries the revision it was based on. The server transforms it past newer ones, appends it to the log, and assigns the next revision.
Keep one edit in flight per client
The client sends one edit, buffers the rest, and transforms incoming edits past its pending ones. This keeps the protocol simple and correct.
Acknowledge after the log write
An edit is durable only once the log append succeeds. Clients keep unacknowledged edits and resend them by operation id.
Snapshot and recover
Snapshot every few hundred edits. After a crash, the new server loads the snapshot and replays the log tail.
Handle offline and reconnect
On reconnect, send the last revision seen and the queued edits. The server transforms them past what was missed.
Say when you would choose a CRDT
For long offline work, peer-to-peer sync or no central server. Mention growth and compaction.
Implementation
Tabs: the transform function and its tests, the client and server protocol, and a sequence (RGA).
Read more · 105 words
- The OT test checks convergence for 5,000 random pairs of operations, and for three clients with random message timing over 200 runs.
- The CRDT test applies the same operations in shuffled order with duplicates and checks every ends equal.
rga.test.jsalso shows : an empty document that still remembers 100 characters.
// Operational transformation (OT) for plain text, one character at a time.
// { type: 'insert', pos, ch, site } put `ch` before the character at index `pos`
// { type: 'delete', pos, site } remove the character at index `pos`
// { type: 'noop' } nothing (what a delete becomes when someone already deleted that character)
// `site` is the author's id; it breaks the tie when two people insert at the same position.
//
// transform(a, b) answers: "a was made on the same document as b, but b has already been applied.
// What should a become so it still means the same thing?" Guarantee (TP1):
// apply(apply(doc, a), transform(b, a)) === apply(apply(doc, b), transform(a, b))
export function apply(doc, op) {
if (op.type === 'insert') return doc.slice(0, op.pos) + op.ch + doc.slice(op.pos);
if (op.type === 'delete') return doc.slice(0, op.pos) + doc.slice(op.pos + 1);
return doc;
}
export const insert = (pos, ch, site) => ({ type: 'insert', pos, ch, site });
export const del = (pos, site) => ({ type: 'delete', pos, site });
export function transform(a, b) {
if (a.type === 'noop' || b.type === 'noop') return a;
if (a.type === 'insert' && b.type === 'insert') {
if (a.pos < b.pos || (a.pos === b.pos && a.site < b.site)) return a; // a stays left of b
return { ...a, pos: a.pos + 1 }; // b's character is now before a
}
if (a.type === 'insert' && b.type === 'delete') {
return a.pos <= b.pos ? a : { ...a, pos: a.pos - 1 };
}
if (a.type === 'delete' && b.type === 'insert') {
return a.pos < b.pos ? a : { ...a, pos: a.pos + 1 };
}
// delete vs delete
if (a.pos < b.pos) return a;
if (a.pos > b.pos) return { ...a, pos: a.pos - 1 };
return { type: 'noop' }; // both deleted the same character
}
// Transform one op past a whole list of ops that were applied before it.
export const transformAgainst = (op, others) => others.reduce((o, other) => transform(o, other), op);Failure modes and trade-offs
Read more · 179 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Session server crashes | Editing pauses | Reassign the document; snapshot plus log replay |
| Duplicate delivery | An edit applied twice | Operation ids; the server ignores repeats |
| Client offline for long | Large rebase | Transform queued edits past missed ones; cap the queue |
| Transform bug | Copies diverge | Property tests; periodic checksum of the document |
| Hot document, 1,000 editors | One server saturated | Read-only viewers on a fan-out tier; limit editors |
| Log grows forever | Storage cost | Snapshots; compact old operations |
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| OT with a central server | Compact, no tombstones | Transform functions are subtle; needs a server |
| CRDT | Works peer-to-peer and offline | Ids and tombstones grow memory |
| Last-writer-wins on the whole doc | Trivial | Loses people's edits |
| Lock the paragraph | Simple conflict story | Blocks people; poor feel |
Follow-up questions
How do you undo?
Undo is a new operation that reverses your last edit after transforming it past everyone else's later edits, not a rollback of the document.
How do you scale one very popular document?
Keep editing on one server, and read-only viewers through a separate tier that follows the log.
How do you test a transform function?
Property tests: random pairs of operations must converge in both orders, and random multi-client runs must end equal.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.