Storage & Data
Design Google Docs / Live Collaborative Editor
01
What we are building
What we are building
Picture a team of six drafting a launch plan in one document. Two of them are rewriting the same paragraph, one is fixing typos further down, and a manager is reading along with comments open. Each person sees their own typing with zero delay, because the editor applies it locally first. Within a fraction of a second, everyone else sees it too, placed correctly even though the text around it has shifted.
The system behind this has three jobs. It must order concurrent edits so all copies converge, persist every change so a crash never loses work someone saw as saved, and broadcast changes and cursors to everyone in the document with low latency. Around that core sit permissions, comments, version history, export and search, which matter for the product but are much easier to scale.
Locking a paragraph while someone types would make convergence trivial and the product unusable. So the design must let every client edit optimistically, without waiting for the server, and then reconcile. Whatever reconciliation scheme you choose is the heart of the interview.
02
Scope and targets
Scope and targets
- Create, open, rename and delete rich-text documents, organised by owner and shared folder.
- Several people edit at once and see each other's changes while they type, not on save.
- Live cursors and selections for every collaborator, with a name and colour.
- Share with view, comment or edit rights, by person or by link.
- Threaded comments anchored to a text range that follow the text as it moves.
- Version history: browse earlier states, see who changed what, restore any named version.
- Keep editing through a short network drop and sync when the connection returns.
- Local keystrokes render in the same frame; remote edits arrive in under 200 ms p95 within a region.
- Every replica of a document converges to identical content once edits stop.
- An edit the server has acknowledged is durable: no data loss after an ack.
- Opening a document of 50 pages takes under a second on a normal connection.
- Editing stays available during a single zone failure; a few seconds of reconnect is acceptable.
- Revoked access takes effect on open sessions within seconds.
How many simultaneous editors must one document support: ten, a hundred, a thousand? Is long offline editing in scope, or only brief disconnects? Do we need spreadsheets and slides too, or only text? The answers move you between a server-sequenced design and a peer-friendly one, so get them before drawing boxes. For this walkthrough: rich text, up to 100 concurrent editors per document, many more viewers, and offline gaps of minutes rather than weeks.
03
Back-of-envelope numbers
Back-of-envelope numbers
Assume 25 million people open at least one document each day, and that at the busiest hour about 4 million editor sessions are open at once. Most sessions are idle reading. Clients batch keystrokes into one message every 250 ms while the user is typing.
| Quantity | Working | Result |
|---|---|---|
| Open sessions at peak | assumption | 4M |
| Sessions actively typing | about 8% of open sessions | 320K |
| Inbound edit batches | 320K x 4 batches / s | about 1.3M / s |
| Outbound fan-out | 1.3M x 1.6 other sessions per busy doc | about 2.1M / s |
| Cursor updates | throttled to 5 / s per moving cursor, 600K moving | about 3M / s, ephemeral |
| WebSocket gateway nodes | 4M connections / 40K per node, plus 50% headroom | about 150 nodes |
| Average op rate across the day | roughly a third of peak | about 430K / s |
| Ops written per day | 430K x 86,400 | about 37B |
| Op log growth | 37B x about 110 B with metadata | about 4 TB / day |
| Documents stored | assumption | 6B |
| Snapshot storage | 6B x 45 KB average compressed | about 270 TB, before replication |
The aggregate rate is large but spread across millions of independent documents. Any single document sees at most a few hundred ops per second, even with 100 people typing. That means per-document work can live on one machine, and the system scales by spreading documents, not by splitting one document.
Four terabytes a day of raw ops is more than the snapshots grow. Keep fine-grained ops for 30 days, then compact them into coarser revisions. Users rarely need keystroke-level history from last year; they need named versions and daily checkpoints.
04
Interfaces: REST for documents, a socket for editing
Interfaces: REST for documents, a socket for editing
Document management is ordinary request and response. Editing is a long-lived, ordered stream, so it gets its own WebSocket protocol. Keep the two apart: the socket carries only what must be fast.
The editing socket
Describing an edit as a walk over the document (skip 1,042 characters, insert ten, delete three) makes every op composable and transformable with simple rules. Formatting is a retain with attributes. The client keeps one op in flight at a time and buffers the rest, which keeps the protocol state tiny.
05
How the pieces fit
How the pieces fit
flowchart LR
C["Editor client"] -->|"WebSocket"| G["Session gateway"]
C -->|"HTTPS"| API["Document API"]
G --> RT["Router: doc to owner"]
RT --> DS["Document sequencer, one owner per doc"]
DS --> OL[("Op log, keyed by doc and revision")]
DS --> PB[["Presence fan-out"]]
PB --> G
DS -. "every 500 ops" .-> SN["Snapshotter"]
SN --> OS[("Snapshot object store")]
API --> ACL[("Permissions store")]
API --> OS
OL -. "change feed" .-> IX["Search and export workers"]
LS[("Lease table")] --- RTThe design turns on one rule: every live document has exactly one owner process, the sequencer. All edits for that document pass through it, it assigns them consecutive revision numbers, and it is the only writer to that document's slice of the op log. A sequencer process hosts thousands of documents, but each document lives on one sequencer at a time.
When a client opens a document, it loads the latest snapshot and the ops after it through the document API, then opens a socket to any session gateway. The gateway asks the router which sequencer owns the document. Ownership is a lease in a small strongly consistent table; if no lease exists, the router picks a sequencer with spare capacity and the sequencer claims it. From then on the gateway forwards that client's submits to the owner and relays everything the owner broadcasts.
On each submit the sequencer transforms the incoming batch against any ops committed since the client's base revision, appends the result to the op log with a conditional write on the next revision number, then acks the author and broadcasts to the other sessions. Cursors take a separate, lossy path: they go through the same gateways but are never written down.
Hold the sockets, authenticate the user once at connect, and know nothing about document content. They are cheap to scale and cheap to restart, which matters because deploys reconnect every user on the node.
Keeps the current text, the recent op history and the session list for each document in memory. It is the single place where ordering is decided, so no distributed consensus runs per keystroke.
A wide-column or ordered KV store with rows keyed by (doc_id, revision). A conditional insert on the next revision doubles as a fencing check if two sequencers ever believe they own the same document.
Every few hundred ops, a background job folds the log into a full document state and writes it to object storage. Opening a document reads one snapshot and a short tail, never the whole history.
Checked at open and at join. Role changes publish an event that the sequencer uses to downgrade or drop sessions, so a removed editor cannot keep typing for an hour on an old socket.
Search indexing, PDF export, word counts and notifications all read the op log asynchronously. None of them sit on the typing path.
06
Deep dive: making concurrent edits converge
Deep dive: making concurrent edits converge
The document says cat. Ana inserts s at position 3 to make cats. At the same moment Ben inserts a at position 0 to make a cat. Each applies their own edit first. If each then applies the other's edit with its original position, Ben gets a cast because position 3 now points inside the word. The copies have diverged, and nothing will bring them back.
Positions are only meaningful relative to a particular version of the text. Every concurrent editing scheme is a way to deal with that fact. There are two families. Operational transformation keeps positions and adjusts them. CRDTs stop using positions and give every character a permanent identity instead.
Operational transformation with a central sequencer
A transform function takes two ops that were made against the same version and rewrites one so it can apply after the other. For two inserts, if the other insert landed at or before your position, shift yours right by its length. For an insert against a delete, shift left by however many deleted characters came before you. When two inserts land at the same position, break the tie with a fixed rule such as lower user id goes first, so every replica makes the same choice.
In the example, the server receives Ben's op first and gives it revision 101. Ana's op arrives saying "based on revision 100". The server transforms it past Ben's insert of two characters at position 0, so Ana's insert moves from position 3 to position 5, and commits it as revision 102. Ben receives insert "s" at 5 and gets a cats. Ana receives Ben's original op, transforms her own pending state the same way, and also lands on a cats.
Peer-to-peer OT is notoriously hard to get right, because transforms must satisfy properties that many published algorithms turned out to violate. The trick that makes production OT manageable is the one used by Jupiter and later by Google Wave: a single server defines the order. Each client only has to transform against one linear history, and each client has at most one unacknowledged batch in flight. The client keeps two pieces of state: the batch awaiting an ack, and a buffer of edits made since. Incoming remote ops are transformed against both before being applied to the screen.
flowchart TD
A["Submit arrives: base revision 100, client seq 311"] --> D{"Seen client seq 311 already?"}
D -->|yes| R["Resend the earlier ack"]
D -->|no| H["Fetch committed ops 101 to head"]
H --> T["Transform batch past each of them"]
T --> W["Conditional append at head plus 1"]
W -->|"conflict: lost lease"| X["Drop session, client reconnects"]
W -->|ok| K["Ack author with new revision"]
K --> B["Broadcast transformed op to other sessions"]CRDTs: identities instead of positions
A sequence CRDT such as RGA or YATA gives each inserted character an id made of a site id and a counter, and records which character it was inserted after. An insert says "put s after character (ana, 3)", which means the same thing on every replica no matter what else has been inserted nearby. Deletes mark a character as a tombstone instead of removing it, because a concurrent insert may still refer to it. Any replica that has received the same set of ops, in any order, renders the same text. No server needs to order anything.
The price is metadata. Every character carries an id and a parent reference, and tombstones accumulate. Modern libraries like Yjs and Automerge compress runs of characters typed in sequence, which brings the overhead down to a small multiple of the text size for typical documents, but garbage collecting tombstones safely needs to know every replica has seen the delete, which is hard when clients can be offline indefinitely. Rich text adds more subtlety: formatting spans, list structure and tables all need CRDT designs of their own.
| Question | Server-sequenced OT | Sequence CRDT |
|---|---|---|
| Who decides order | The document's sequencer | Nobody; merge is order-free |
| Server role | Required on the edit path | Relay and storage only |
| Per-character overhead | None in the stored document | Ids and tombstones |
| Long offline editing | Possible but transforms pile up | Natural fit |
| Peer-to-peer or local-first | Poor fit | Designed for it |
| Implementation risk | Transform functions for every op pair | Data structure correctness and memory |
Persistence and the open path
The sequencer acks only after the op log write succeeds, so an acked edit is never lost, even if the sequencer dies the next millisecond. Writes go to a replicated store within the region, which costs a few milliseconds per batch. Batching helps: the sequencer can group submits from many sessions of the same document into one log write when traffic is heavy.
To open a document, read the newest snapshot, which records the revision it reflects, then read log rows from that revision to the head and apply them. With a snapshot every 500 ops, the tail is never long. When a sequencer takes over a document after a crash, it does the same thing to rebuild memory, then accepts submits. Clients that had a batch in flight resend it with the same client_seq; the sequencer recognises duplicates from a small per-session table stored with the log, so a retry never inserts text twice.
Use OT with one sequencer per document for the product described here. The server is on the path for permissions and persistence anyway, the stored document stays compact, and the hard theory of peer-to-peer OT disappears. If the requirements shift towards weeks of offline editing or local-first apps, reach for a CRDT library and keep the same gateway, log and snapshot layout.
07
Choices and their costs
Choices and their costs
Full duplex with low per-message overhead, which suits a stream of small ops and cursor updates in both directions. The cost is long-lived connections, careful draining on deploys and load balancers that understand upgrades.
Best fit for live editingWorks through restrictive proxies and is simpler to operate. Each submit becomes its own request with headers and auth, and ordering across two channels needs extra care. Keep it as a degraded mode, not the main path.
Fallback onlyOrdering happens in memory with no locks across machines, and transforms run against a hot op history. You pay with lease management, a router, and a few seconds of unavailability for one document when its owner fails.
Simple and fastEvery submit would read the latest revision, transform, and attempt a compare-and-set. Under contention on a busy document, writers retry in a loop and latency climbs exactly when the most people are watching.
Contention under loadA user who sees "saved" really is saved. It adds a replicated write, a few milliseconds, to every batch. Local rendering hides that from the person typing.
No lost acknowledged editsLower latency on paper, but a sequencer crash can discard seconds of edits that collaborators already saw on screen. Restoring them later is impossible because remote clients already built on top.
Silent loss on crashThe log gives precise history and replay; snapshots keep opens fast. Compaction after 30 days turns keystroke ops into hourly revisions so storage stays bounded.
History and fast opensSmallest footprint and easiest reads. You lose history, attribution and the ability to rebuild a sequencer from scratch, and a corrupt write overwrites the only copy.
Too little to recover fromNo permission lookup per keystroke. A role change publishes an event, and the sequencer immediately downgrades or closes affected sessions.
Cheap and still promptThe most direct way to enforce access, and a permissions service now has to answer over a million checks a second at peak for answers that almost never change.
Cache it if you must08
When things break
When things break
Every session on those documents loses its owner at once. Clients keep typing locally, but nothing is acked and remote edits stop arriving.
Leases expire within a few seconds; the router reassigns each document to a healthy sequencer, which loads the snapshot and log tail. Clients reconnect, resend their in-flight batch with the same client sequence number, and carry on. Spread reassignment across many machines so no single node absorbs the whole load.A long garbage collection pause lets a lease expire. A new owner takes over, and then the old one wakes up and keeps committing. Two histories with the same revision numbers would corrupt the document.
Make the log the referee. Each append is conditional on the exact next revision and carries the lease epoch. The stale owner's write fails, it drops its sessions, and only one history can ever be written.Restarting a node closes 40,000 sockets. All of them reconnect in the same second, each one fetching a snapshot and rejoining, and the document API spikes.
Drain nodes gradually with a close message that carries a randomised retry delay, rejoin withhave_revision so the server sends only the missing ops, and roll deploys a few percent of nodes at a time.
One sequencer must now fan each op out to thousands of sockets and process a flood of cursor updates, starving its other documents.
Separate editors from viewers. Viewers receive committed ops through a pub/sub topic rather than directly from the sequencer, cursors are sampled for large audiences, and the number of simultaneous editors is capped with a clear message.The client holds 40 minutes of unsent edits based on a revision that is now 20,000 ops old. Transforming one batch past all of them is slow, and the result may be surprising for the author.
Transform in chunks on the server with a time budget. If the gap passes a threshold, fetch the latest snapshot, rebase the local edits against it on the client, and show the author a short summary of what changed underneath.A single op that large blocks the sequencer, bloats the log row and stalls every other client in the document while they download it.
Limit op and document sizes, split large pastes into chunks, and store embedded images and attachments in object storage referenced by id instead of inline.09
Interview notes
Interview notes
Show the divergence bug before the fix. Two concurrent inserts and a broken result take thirty seconds to draw and prove you know why this problem is hard.
Name the single ordering point. Saying "one sequencer per document, assigned by lease" answers how ordering works, how it scales, and what fails, all in one breath.
Compare OT and CRDTs on the requirements, not on fashion. Server in the loop and short offline gaps favour OT; local-first and peer-to-peer favour CRDTs. Interviewers want a reasoned pick with its cost.
Be precise about acknowledgements. State when the server acks and what is durable at that moment. Then walk through what a client does with an op that was sent but never acked.
Treat presence as a separate channel. Cursors are high volume, low value and safe to drop. Keeping them out of the log is a small detail that signals good instincts.
Expect the split-brain question. Conditional appends with a lease epoch are the clean answer. Leases alone are not, because pauses happen.
Mention the open path. Snapshot plus log tail is how a 300-page document still opens in under a second, and how a new sequencer rebuilds state.
Keep side features asynchronous. Search, export and notifications read the change feed. Say this explicitly so nobody assumes they slow down typing.
10
From prototype to global product
From prototype to global product
One editor at a time, others get read-only. A Postgres row holds the content. It works for a small team and teaches you how much people hate waiting for a lock.
A single app server holds every open document in memory, sequences ops with OT and writes them to an append-only table. Snapshots are taken on close.
Split socket handling from sequencing, assign documents to sequencers through a lease table, and move the op log to a partitioned store. Snapshots become periodic.
Search and export move to log consumers, old ops get compacted into revisions, and viewers of very popular documents move onto pub/sub fan-out.
Run sequencers in several regions and move a document's lease to the region where most of its active editors are. Cross-region collaborators pay one long hop, and a regional failure moves leases elsewhere.
Watch and read
Further watching and reading
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
