Design Google Docs
Real-time collaborative editing: many cursors in one document, every keystroke converging to the same text, offline edits merged on reconnect.
Last updated 2026-09-22. Difficulty: hard. Patterns: crdt, operational-transform, websockets, conflict-resolution. Reported at Google and 5 more with Pro.
The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the coach anything along the way.
Functional requirements
- Multiple people edit one document at the same time. Edits appear for everyone within a couple of hundred milliseconds, and every client ends up with byte-identical text regardless of the order updates arrived.
- Local edits are never blocked. Typing applies to the local copy immediately and is sent in the background. A design that waits for the server before showing a character is unusable, which is what forces the conflict-resolution machinery.
- Presence and cursors. Who is in the document, where their cursor is and what they have selected. Ephemeral, lossy and high frequency: never stored, never allowed to block an edit.
- Offline editing and reconnect. Edits made while disconnected are queued locally and merged on reconnect without a manual conflict dialog.
- Version history and named revisions. Browse and restore earlier versions, see who changed what. This is the durable artefact; the live editing session is not.
- Comments and suggestions anchored to text. An anchor must survive concurrent edits around it, which is a different problem from anchoring to a character offset.
- Sharing and permissions. Owner, editor, commenter, viewer, plus link sharing. Permission changes take effect on the live session, not just on the next open.
- Rich text, not plain text. Bold, headings, lists, tables and images. Formatting is a concurrent operation too: two people bolding overlapping ranges must converge.
- Out of scope. Real-time video and chat in the document, the full editor UI, spreadsheet formula evaluation, and offline-first desktop sync clients.
Non-functional requirements
- Edit propagation latency (p95 < 200 ms same region). Beyond about 300 ms, collaborators start colliding because they no longer see each other in time. This is the requirement that keeps sessions pinned to one region.
- Local echo latency (< 16 ms). A keystroke renders on the next frame, always. The server is never on the path of showing your own character.
- Convergence (strong eventual consistency). Every replica that has seen the same set of operations shows the same document, no matter what order they arrived in. This is the correctness property the whole design exists to provide.
- Durability (no acknowledged edit lost). Once the client shows "saved", the operation survives a server crash. This is what pins the write path to a replicated log with a real fsync.
- Concurrent editors (100 per document · 50 typical). The product limit matters: fan-out per document is bounded, so the hard part is document count, not per-document scale.
- Scale (50 M documents edited per day · 5 M concurrent sessions). Sessions are long-lived WebSockets, so connection count, not request rate, sizes the fleet.
- Availability (99.99 % for open and edit). A document that cannot be opened blocks work. Degraded mode is read-only with local edits queued, never a hard error.
- History retention (full history, restorable). Operations are kept and compacted into snapshots. History is the feature that justifies storing the operation log rather than just the text.
Back-of-envelope estimates
- Operations per second at peak: ~150 k/s. 5 M concurrent sessions, of which maybe 3 % are actively typing at any instant = 150 k typists. Batched at roughly one operation per 100 ms of typing, that is ~150 k ops/s arriving at the servers.
- Fan-out messages per second: ~750 k/s. Each operation is broadcast to the other collaborators in that document. With an average of 6 people in an actively edited document, 150 k × 5 = 750 k pushes/s. This is the number that sizes the connection tier.
- Session servers: ~100–200. 5 M connections ÷ ~30 k connections per server = 167 servers, and the same fleet must sustain 750 k pushes/s, which at 5 k pushes/s per server would need 150. Both point at the same order, so budget 200 across three availability zones.
- Operation log volume: ~1.3 TB/day. 150 k ops/s × 86 400 s = 13 B ops/day at peak-equivalent, realistically ~5 B/day averaged, at ~150 B each (id, site, position, character, attributes) plus index overhead ≈ ~1 TB/day raw, ~3 TB replicated.
- Document size and snapshot cost: ~50 KB text · ~200 KB with metadata. A 20-page document is ~50 KB of text. A CRDT carries per-character metadata (site id, counter, tombstones) that can be 3–10× the text before compaction, so snapshots are compacted and tombstones garbage collected once every client has acknowledged past them.
- Presence traffic: ~500 k msgs/s. Cursor moves are far more frequent than edits: throttled to 10/s per active user, 50 k moving cursors gives 500 k/s. It is dropped under load, never persisted, and never allowed to queue behind edits, which is why presence runs on its own channel.
- Storage for history: ~1 PB/year. 1 TB/day of operations × 365 = 365 TB/yr raw, ×3 replication ≈ 1.1 PB/yr. Compaction into snapshots plus dropping fine-grained operations older than 30 days (keeping named versions) cuts this by an order of magnitude, which is the actual design decision.
- Reconnect storm after a deploy: ~1.7 k reconnects/s. Draining 200 servers over 10 minutes moves 5 M ÷ 600 s ≈ 8 k connections/s; with jittered backoff spreading each server over 30 s the peak is nearer 1.7 k/s. Each reconnect costs an auth check, a session lookup and a catch-up, so this is what sizes the session store.
Components
- Editor client (local replica + queue): Holds a full replica of the document and applies every local edit immediately, so typing never waits for the network. Keeps an outbound queue of unacknowledged operations, a vector of what it has seen, and enough state to reconcile after a disconnect. Renders remote cursors from the presence channel, which it treats as disposable.
- WebSocket gateway (sticky · per document): Terminates the long-lived connection, authenticates the session once at connect, and routes the client to the session server that owns this document. Sticky by document rather than by user, because the whole design depends on one owner per document ordering the operations.
- Session server (one owner per document): The in-memory authority for a live document: holds the current state, assigns each incoming operation a position in the sequence, rebroadcasts it to the other collaborators, and periodically persists. Single-owner per document is what turns a hard distributed problem into an ordered log with fan-out.
- Ownership registry (lease per document): Maps document id to the session server that currently owns it, as a short lease refreshed by the owner. A new connection looks here first; if there is no owner, it claims the lease and loads the document. The lease plus a fencing token is what prevents two servers from ordering the same document.
- Operation log (append-only · per document): The durable record: every acknowledged operation with its sequence number, author and timestamp, partitioned by document id. Append-only writes, range reads by sequence, and a retention policy that compacts old ranges once a snapshot covers them. This log is both the crash-recovery mechanism and the history feature.
- Snapshot store (object storage · compacted state): Periodic compacted document states, so opening a document is "load the newest snapshot plus the operations after it" instead of replaying a million edits. Snapshots also carry the garbage-collected structure with tombstones removed once every replica has moved past them.
- Document metadata (Postgres · title, owner, ACL): Title, owner, folder, sharing rules and per-user roles. Small, relational and strongly consistent, because a permission revoke must be immediately effective. Read on open and watched by the session server so a revoke can eject a live collaborator.
- Presence channel (Redis pub/sub · TTL): Cursor positions, selections and who is in the document, with a few seconds of TTL and no durability. Deliberately separated from the edit path so a burst of cursor movement can be dropped without touching correctness.
- Event bus (Kafka · keyed by doc id): Carries committed operations and document events to everything that is not the live session: search indexing, notifications, comment anchoring, analytics and export. Keying by document id keeps a document's events ordered for every consumer.
- History service (versions · restore · diff): Builds the version timeline from the operation log and snapshots, groups operations into human-sized revisions by author and time gap, renders diffs, and performs a restore by appending the inverse operations rather than rewriting history.
- Comments service (anchored to positions): Comment threads and suggestions, each anchored to a stable range identifier rather than a character offset so concurrent edits do not move it to the wrong text. Handles the case where the anchored text is deleted by marking the comment orphaned instead of losing it.
- Search indexer (debounced per document): Consumes document events and reindexes the text, debounced so a document being typed into is indexed once every few seconds rather than on every keystroke. Permission fields are indexed alongside so queries filter by access inside the index.
- Notification service (mentions · shares): Turns document events into notifications: mentions in comments, share invitations, suggestions awaiting review. Batched and deduplicated, because a burst of edits must not become a burst of emails.
- Media storage (images · signed URLs): Images and attachments, uploaded directly from the client with a pre-signed URL and referenced from the document by id, so the operation log never carries binary payloads and a pasted screenshot does not enter the edit stream.
User flows
- Open a document and join the session. Everything that has to happen before the first keystroke: authorise, find or create the owner, load a state without replaying history, and start receiving other people's edits.
- Client opens a connection and presents its token and the document id. Authentication happens once per connection rather than per operation, which is the main reason a long-lived socket beats a request per keystroke.
- Gateway checks the metadata store for the caller's role on this document. Viewer, commenter and editor produce different session capabilities. The role is cached for the session but re-checked when the metadata service publishes a change, so a revoke ejects a live collaborator rather than waiting for a reload.
- Gateway asks the ownership registry which session server owns this document. One owner per document is the pivotal decision: it makes ordering a local problem. If there is no owner, the gateway picks a server by consistent hashing on the document id and that server claims the lease.
- The owning session server claims or renews the lease with a fencing token. The lease expires unless renewed, so a crashed owner releases the document automatically. The epoch is carried into every write to the operation log, so a paused old owner that wakes up cannot append after a new owner has taken over.
- Session server loads the newest snapshot and replays only the operations after it. Opening a document with a million edits must not replay a million operations. Snapshots every few thousand operations bound the replay to a fraction of a second.
- Client receives the state plus the sequence number it is caught up to, and subscribes to presence. The sequence number is the resume point: everything the client does later is relative to it, and it is what makes a reconnect a cheap catch-up instead of a reload.
- Two people type in the same paragraph. The core loop, and the reason this question is hard: both edits apply locally first, arrive in different orders elsewhere, and must still converge to identical text.
- Local edit applies to the client replica immediately and renders on the next frame. Optimistic local application is non-negotiable at 16 ms; everything else in the design is a consequence of it. The operation goes into the unacknowledged queue at the same time.
- Client batches operations from the last few keystrokes and sends them over the socket. Batching at roughly 50–100 ms turns per-character chatter into one message per typing burst, cutting message rate by an order of magnitude with no perceptible delay.
- Session server places the operations into the document sequence and resolves them against concurrent edits. This is where the conflict rule lives. With a CRDT, each character has a unique identifier and a total order, so concurrent inserts at the same point are ordered deterministically by identifier and no transformation is needed. The server is an ordering authority and a fan-out point, not an arbiter of meaning.
- Operations are appended to the durable log before they are acknowledged. Acknowledge after the append, not before: the client shows "saved" based on this, and a crash must not lose an acknowledged edit. Appends are batched per document to amortise the write.
- Server broadcasts the operations to every other collaborator and acknowledges the author. The author gets a sequence number that lets it drop the operations from its unacknowledged queue; everyone else gets the operations to apply. One message per recipient per batch, which is what the fan-out estimate counts.
- Remote clients apply the operations to their replicas, rebasing their own unacknowledged edits. Because the operations carry stable identities, a client that has pending local edits can apply the remote ones underneath them and still converge. Cursor positions are adjusted at the same time so a collaborator's caret does not jump.
- The committed operations are published to the bus for everything that is not the live session. Search, history grouping, comment anchoring and notifications all consume this stream asynchronously, so none of them can slow down typing.
- Cursors, selections and who is here. Five times the message volume of editing, none of it durable. Kept on its own path so it can be throttled and dropped without ever affecting the document.
- Client throttles cursor movement to about ten updates per second. A moving cursor generates events at the frame rate; throttling on the client is what stops 50 k collaborators from generating millions of messages a second.
- Presence updates go to the presence channel, not into the operation stream. Separate channel, separate backpressure. Under load the presence channel sheds messages and cursors lag; the edit path is untouched. Mixing the two is the mistake this step exists to avoid.
- The session server subscribes and merges presence into the collaborator list it broadcasts. Batched into one message per 100 ms per document rather than one message per cursor move, so the fan-out is bounded by document count rather than by cursor activity.
- Entries carry a TTL so a disappearing client fades out without an explicit goodbye. A browser tab that is closed, crashed or on a train sends nothing more; a few seconds of TTL removes it. Explicit leave messages are an optimisation, never the mechanism.
- Cursor anchors are expressed as character identities, so they survive concurrent edits. If a cursor were a numeric offset, every insert above it would move someone else's caret. Anchoring to the identity of the character it sits after makes remote edits shift the rendering without changing the anchor.
- Edit offline, then reconnect. The case that rules out any design where the server must see an edit before it exists. Hours of local edits merge without a dialog, and the client catches up on what it missed.
- The connection drops; the client keeps applying edits to its local replica and queueing them. The editor is fully usable offline because the replica is authoritative locally. The queue is persisted to local storage so closing the laptop does not lose the work.
- On reconnect, the client presents its last acknowledged sequence number and its queued operations. A resume, not a reload: the server sends only what the client missed. Reconnects are jittered so an outage does not become a synchronised stampede.
- Server streams the missed operations; the client applies them beneath its pending edits. If the gap is larger than the retained window, the server sends a fresh snapshot instead and the client rebases its pending operations onto it. That threshold is a real design number: a day of edits is a snapshot, a minute is a catch-up.
- Client sends its queued operations, which the server merges with everything that happened meanwhile. There is no conflict dialog because there is no conflict to resolve: the identities in each operation determine where the text belongs, whatever happened in between. What the user may see is text that has moved, which is why the editor scrolls to the merged region rather than silently reflowing.
- Operations are appended and broadcast like any other, and the client is marked caught up. Offline edits are not special in the log. They arrive late, they are ordered deterministically, and history shows them at the time they were made rather than the time they arrived.
- Tombstones and metadata are garbage collected once every live replica has acknowledged past them. Deleted characters cannot be discarded while an offline client might still reference them, so collection waits for the minimum acknowledged sequence across active clients, with a cutoff (say 30 days) after which a very stale client is forced to resynchronise from a snapshot.
- Version history, comments and restore. The asynchronous half of the product. Everything here reads the operation log rather than the live session, so none of it can slow down typing.
- A background compactor writes a snapshot every few thousand operations. Snapshots bound both open time and recovery time. They are written from the session server when the document is live, and from a job that replays the log when it is not.
- The history service groups operations into human-sized revisions. Nobody wants a version per keystroke. Operations are grouped by author and by gaps in time, producing entries like "Sam edited, 14:02–14:17", which is the unit the version panel shows.
- Opening a version renders the document at that point from the nearest snapshot plus a replay. The same mechanism as opening the live document, at an earlier sequence. Diffs between two versions are computed from the operations between them rather than by text differencing, which is both cheaper and more accurate about who changed what.
- Restoring a version appends new operations rather than rewriting the log. A restore is an edit: compute the difference between now and the target and apply it as operations. History stays append-only, the restore itself is undoable, and live collaborators see the change arrive like any other edit.
- Comment anchors are rebased as operations flow past, and orphaned when their text is deleted. A comment anchored to a character range follows the text as it moves. When the anchored text is deleted, the comment is marked orphaned and shown in the sidebar with its quoted text instead of vanishing, which is what users expect.
- Search reindexes the document a few seconds after typing stops, and mentions become notifications. Debounced per document so an actively edited document is indexed once per quiet interval rather than continuously. Notifications are batched per recipient so a comment thread does not produce ten emails.
- Images pasted into the document are uploaded directly to media storage and referenced by id. Binary payloads never enter the operation stream: the client uploads with a pre-signed URL and inserts an operation containing the object id, so a pasted screenshot is one small operation instead of a megabyte in the log.
Deep dives
Operational transformation or CRDT
How do concurrent edits to the same region converge, and what does that choice cost?
Two people insert at the same position at the same time. Both applied locally first, so neither can be rejected; the algorithm decides what everyone ends up seeing.
Character offsets are meaningless across replicas: by the time an operation arrives, the text it referred to has moved.
The choice determines whether a central server is required, how much metadata each character carries, and how hard the implementation is to get right.
- CRDT with unique character identifiers chosen
- Operational transformation with a central server situational: A reasonable choice when a single ordering server already exists and the document model is simple; the metadata savings are real.
- Locking regions (one editor per paragraph) rejected
- Last write wins on whole document rejected
The answer: A sequence CRDT with unique, totally ordered character identifiers, a single session server per document for ordering, durability and fan-out, and compaction to control the metadata cost.
If the CRDT does not need a central server, why have one per document?
For everything other than convergence: durable ordering into a log, a single place to enforce permissions, fan-out to collaborators without a mesh, snapshotting, and a well-defined point for garbage collection. The CRDT means a brief period with two owners is a performance problem rather than a corruption problem, which is exactly the safety margin you want.
How large does the metadata actually get?
Each character carries an identifier (site id plus counter, around 8–16 bytes) and deleted characters remain as tombstones until collected. A 50 KB document can sit at 200–500 KB before compaction. Mitigations: block-wise identifiers that cover runs of characters rather than one per character, periodic snapshots that renumber, and tombstone collection once every active replica has acknowledged past them.
What does interleaving look like, and does it matter?
Two people typing different words at the same position can produce characters interleaved rather than one word after the other. It is deterministic and identical everywhere, so it is not a correctness bug, and modern algorithms reduce it by keeping runs together. In practice it is rare because collaborators see each other's cursors and do not type in the same spot.
How do you test convergence?
Property-based testing: generate random operation sequences, deliver them to several replicas in different random orders with duplicates, and assert all replicas are byte-identical and that no acknowledged operation is missing. Plus a replay harness over recorded production sessions. This is the test suite that matters most in the whole system.
One session server per document
How is a document assigned to a server, and what happens when that server dies mid-session?
Ordering, fan-out and persistence are all much simpler with one owner, but an owner is also a single point of failure for that document.
Millions of documents are open at once, so assignment must be cheap and require no central registry lookup per operation.
A paused owner that comes back to life must not be able to append to the log after someone else has taken over.
- Lease in a registry plus consistent hashing, fenced by an epoch chosen
- Pure consistent hashing with no lease rejected
- Any server can serve any document, merging through the CRDT situational: Attractive for a multi-region product where collaborators are genuinely spread across continents, at the cost of a substantially harder persistence story.
- Consensus group per document rejected
The answer: Consistent hashing to pick a candidate, a short lease in the ownership registry to make it authoritative, and a monotonic epoch carried into every log append so a returning old owner is fenced out.
What exactly do users see when the owning server crashes?
Their sockets drop, the editor keeps working locally and queues edits, and reconnection is jittered over a few seconds. A new owner claims the lease once it expires, loads the snapshot plus the tail of the log, and the clients resume from their last acknowledged sequence. The visible effect is a "reconnecting" indicator for a few seconds and no lost work, because unacknowledged edits are still in each client's queue.
How do you avoid losing acknowledged operations during that window?
Acknowledgement happens only after the append to the durable log, so anything acknowledged is recoverable by the new owner. Anything not acknowledged is still in the client queue and is resent on reconnect, where duplicates are harmless because operation identities make replays idempotent.
A document has 100 editors and its server is saturated. What do you do?
Move it: the control path can revoke the lease and place the document on a less loaded server, which is a two-second reconnect for its collaborators. Beyond that, the levers are fan-out helpers that relay broadcasts to a subset of clients, and stricter presence throttling, since presence rather than edits is usually what saturates a big session.
How does the gateway know where to route without a registry lookup on every message?
The lookup happens once at connect; after that the socket is bound to that session server for its lifetime. A lease change forces a reconnect, which is an event rare enough to pay for.
Operation log, snapshots and what gets kept
What is stored: every operation, periodic states, or both, and for how long?
History and restore are product features, so the sequence of edits has value beyond crash recovery.
Replaying a million operations to open a document is unacceptable, and so is storing a petabyte a year of keystrokes forever.
Durability is promised at acknowledgement, which means the write path must be a real append with replication, not a periodic save.
- Append-only operation log plus periodic snapshots chosen
- Store only the current document state, saved periodically rejected
- Log forever, never snapshot rejected
- Tiered retention: fine-grained recent, coarse older chosen
The answer: An append-only per-document operation log with snapshots every few thousand operations, fine-grained operations retained for 30 days, and daily plus named snapshots kept for the life of the document in cheaper storage.
What database would you use for the operation log?
A wide-column or log-structured store partitioned by document id with the sequence number as the clustering key: appends are sequential, a catch-up is a single range read of one partition, and old ranges are dropped by time bucket. A relational table works up to a point, but per-document partitioning and cheap range deletion are what the workload actually needs.
How often do you snapshot, and what decides it?
Every few thousand operations or a few minutes, whichever comes first, tuned so that the replay after the newest snapshot stays under about 100 ms. Snapshotting is also triggered before the last collaborator leaves, so a dormant document opens instantly next time.
How do you delete a document for real when a user asks?
Tombstone it immediately so it disappears from every surface, then a job removes the operation ranges, snapshots, search index entries, comment threads and media objects, and records the completion. Backups are handled by retention rather than rewriting: the documented window is when the last copy expires, and encrypting each document with its own key lets the key be destroyed immediately.
A bug corrupts documents. How do you recover?
Because the log is append-only, recovery is replay: rebuild each affected document from the last known-good snapshot plus the operations before the bad deploy, and write the result as a new snapshot with a restore entry in history. This is the concrete payoff of never mutating the log in place.
Fan-out, presence and the message budget
Where do the 750 k broadcasts per second go, and how do you stop cursors from crowding out edits?
Every operation is multiplied by the number of collaborators, and presence multiplies by the same factor again at a higher rate.
The connection tier is sized by connections and by pushes per second, both of which are large before any single document is busy.
Under load, something has to give, and the design must decide in advance what that is.
- Session server broadcasts directly to its own connections chosen
- Broadcast through a pub/sub layer situational: Needed if connections are deliberately not pinned by document, for example to keep clients connected to their nearest edge worldwide.
- Separate ephemeral channel for presence, with shedding chosen
- Client polling for updates rejected
The answer: Direct broadcast from the session server that owns the document, with presence on a separate, throttled, sheddable channel, and batching on both paths so message rate is bounded by documents rather than by keystrokes.
What is the first thing to degrade under overload?
Presence: increase the throttle, coalesce harder, then stop sending cursor updates entirely. Next, increase the edit batching interval from 100 ms to 250 ms, which halves the message rate at a cost users barely notice. Only after that do you reject new sessions on a busy document, and even then existing editors keep working.
How do you deploy without disconnecting five million people at once?
Drain by document: move ownership of documents off a server gradually, with each client receiving a close frame carrying a reconnect delay so the gateway controls the spread. A few percent of documents at a time over ten minutes turns a reconnect storm into a gentle ramp, and the client keeps editing locally throughout.
Collaborators are on different continents. What breaks?
The 200 ms propagation budget, because a round trip to a single owning region can be 150 ms before anything else. Options are to place the document owner near the majority of its collaborators, accept that distant users see more latency, or move to the multi-region merge model, which costs the simple persistence story. The honest answer usually names the first two.
How do you measure whether collaboration feels good?
End-to-end operation latency measured on the client: the time between an edit leaving one client and being applied on another, at p50, p95 and p99, broken down by region. That number, plus the reconnect rate and the share of sessions that ever see a resync, is what the team should page on rather than server CPU.
Permissions on a live session
How are sharing rules enforced when the document is already open and a revoke happens mid-edit?
Permission checks at open time are the easy half; sessions last hours and access can be removed at any moment.
Roles are not binary: viewers, commenters, suggesters and editors produce different allowed operations on the same socket.
Link sharing means the set of people who can open a document changes without the document changing.
- Check at connect, then react to change events on the live session chosen
- Check permissions on every operation rejected
- Short-lived capability tokens refreshed periodically situational: Useful when session servers must not depend on the metadata service being reachable; pair it with a push for immediate revokes.
- Enforce in the client rejected
The answer: Authorise once at connect, cache the role for the session, subscribe to permission-change events per document, and downgrade or eject the affected socket within a second of a change, with every operation still checked against the cached role's capabilities.
What happens to the edits a revoked user made in the gap?
They stay: they were acknowledged and are part of the document, and removing them would rewrite history. The session is closed, the client is told access was removed, and its queued unsent operations are rejected. If the edits were unwanted, the remedy is the restore feature, which is an ordinary edit.
How do commenter and suggester roles work?
They connect to the same session but with capabilities that allow only comment operations or suggestion operations. A suggestion is modelled as an operation tagged as proposed rather than applied: it lives in the log, it converges like any other operation, and accepting it is a second operation that promotes it into the text.
Link sharing is turned off while 50 anonymous viewers are reading. What happens?
The permission change publishes an event, every session server holding that document re-evaluates its collaborators against the new rule, and sockets that no longer qualify are closed with a reason the editor shows. This is the same code path as a single revoke, applied to a set.
How do you keep permission checks off the hot path in search and notifications?
The search index stores the access list as fields so queries filter inside the index, and it is updated from the same permission-change events. Notifications check permission at send time rather than at enqueue time, because a share can be revoked between the two.