Google Docs Design
What is Google Docs? Google Docs is a browser-based collaborative document editor. Users can create rich text documents and collaborate with others in real-time.
In this writeup we’ll design a system that supports the core functionality of Google Docs, dipping into websockets and collaborative editing systems. We’ll start with the requirements (like a real interview), then move on to complete the design.
Functional Requirements
Core Requirements
- Users should be able to create new documents.
- Multiple users should be able to edit the same document concurrently.
- Users should be able to view each other’s changes in real-time.
- Users should be able to see the cursor position and presence of other users.
Below the line (out of scope)
- Sophisticated document structure. We’ll assume a simple text editor.
- Permissions and collaboration levels (e.g. who has access to a document).
- Document history and versioning.
Non-Functional Requirements
Core Requirements
- Convergence. Every collaborator’s screen must end at the identical text, no matter what order their edits happened to arrive in on the server. This is the property we can’t compromise on — everything else in the design supports it.
- Low-latency propagation. An edit made by one user should appear on another user’s screen within tens of milliseconds — collaborative editing has to feel like typing in the same document, not sending messages back and forth.
- Durability. Once an edit is acknowledged to its author, it must survive a server crash. Nothing a user typed should silently vanish.
- Availability. Editing should keep working through individual component failures, and a client that drops its connection should be able to reconnect and pick up exactly where it left off.
- Scale. The system needs to support many documents being edited concurrently, each by a handful of active collaborators. The bottleneck here is live connections and message fan-out, not the raw number of bytes being stored.
The tension worth naming up front: convergence is non-negotiable, but the constraint is achieving it fast enough that two people typing at the same instant never notice the reconciliation happening underneath them. That tension is what the rest of this design is really about.
Set Up
Planning the Approach
The naive version of this system is easy to draw and immediately falls apart the moment two people type into the same document at once — so we’ll build it up in stages, calling out exactly what breaks at each stage and what fixes it, rather than jumping straight to the final architecture.
Defining the Core Entities
- User: A collaborator just enough to identify who’s editing and who’s connected.
- Document: Metadata about the document — id, title, owner, created/updated timestamps. Deliberately does not store the live text as a single mutable field, since that’s exactly the field a naive whole-document save would clobber under concurrent edits.
- Operation (op log entry): The actual source of truth. An ordered, append-only record of every committed edit —
insert(position, text)ordelete(position, length)— each stamped with a monotonically increasing revision number for its document. The document's visible text is always a replay of this log, not a stored value. - Snapshot: A periodic checkpoint of the full text at a given revision, so loading a document or recovering from a crash doesn’t mean replaying its entire history from the beginning.
A concrete storage mapping worth naming. Document metadata and the Operation log both fit a wide-column store like Cassandra: an append-only, time-ordered sequence per document — (document_id, revision) -> { action, msg, index } is exactly what its per-partition clustering is built for. Cassandra specifically earns its place because this is a write-heavy system, every keystroke is a write, and its write path (append to a commit log, then an in-memory memtable, later flushed as an immutable SSTable) is optimized for cheap, sustained appends rather than in-place updates.
A Snapshotby contrast is a large, infrequent blob — a poor fit for a row store, and a natural fit for object storage (S3) instead.
Defining the API
In a real implementation this API splits across two backend services sitting behind one API Gateway — a stateless Document Service for REST reads/writes, and a stateful WebSocket Service for live edits and presence. The gateway is what a client actually talks to, it’s the one that routes each request to the right service underneath.
Document Service: create and load documents (REST)
POST /api/documents
{ "title": "Untitled document" }
-> { "document_id": "...", "revision": 0 }
GET /api/documents/{docId}
-> { "document_id": "...", "s3Url": "...", "revision": 4821 }The GET call itself does almost nothing — it’s a metadata lookup that returns a pointer to the latest snapshot in S3 (or a CDN-fronted URL) and the revision that snapshot was taken at. It doesn’t return the document’s text inline, and it doesn’t touch Cassandra at all. The client fetches the actual content straight from that URL, and it’s only once the WebSocket connection is established afterward that the WebSocket Service publishes every Operation committed since revision 4800 down the socket, which the client applies on top of the fetched snapshot to catch up to the true current state.
WebSocket Service: submit and receive edits and presence (over one persistent connection per open document)
WS PATCH /api/documents/{docId}
{ "base_revision": 4821, "op": { "type": "insert", "position": 12, "text": "x" } }
-> broadcast { "revision": 4822, "op": { "type": "delete", "position": 5, "length": 1 } }
WS POST /cursor/{position}
{ "user_id": "...", "cursor": 34 }base_revision is the piece doing the real work here, it tells the server exactly which state this operation was authored against, which is what makes server-side reconciliation possible. Notice there's no "save the whole document" endpoint, every mutation goes through an operation against a known revision, and a committed operation is what actually gets appended as a ChangeSet row in Cassandra before it's ever broadcast.
High-Level Design
1) Users should be able to create new documents.
This one’s straightforward: POST /api/documents hits the Document Service, which writes a new Document metadata row to Cassandra and an empty initial snapshot to S3, with a fresh document id. Nothing collaborative happens until someone opens it.
2) Multiple users should be able to edit the same document concurrently.
The naive approach: treat every save as “here is the new whole-document text” last write wins. Two people editing at once means the second save silently erases the first person’s changes not a rare edge case, but the normal case the moment two people type in the same document.
Collaborative Edits Breakdown
The fix has two parts. First, edits are modeled as small operations: insert and delete rather than whole-document snapshots, so two concurrent edits can each be recorded and reasoned about individually instead of collapsing into one clobbering write.
Get Niyaz Ahmad’s stories in your inbox
Join Medium for free to get updates from this writer.
Second, every operation for a given document is routed through one session server for that document (assigned via consistent hashing on document_id) this is the role the WebSocket Service plays in a concrete implementation. It assigns the operation the next revision number and runs it through Operational Transformation (OT) against anything that committed since the operation's base_revision.
Concretely if the document reads "abc" and Editor A inserts "x" at position 0 while Editor B, at the same instant, issues delete(2) intending to remove the "c" applying B's operation exactly as written after A's insert has already landed would delete position 2 of "xabc", which is now "b" not "c". OT rewrites B's operation to delete(3) to account for the shift A's insert caused, so it still removes the character B actually meant to delete.
How the revision number actually drives this. The revision counter belongs to the document, not to any one editor it increments by exactly one every time any operation from any client gets committed, in the single serial order the WebSocket Service assigns. Every client’s local copy tracks the revision it’s currently caught up to, and every operation it sends carries that number as base_revision — "this is what I based my edit on." The server's conflict check is a single comparison: does the incoming operation's base_revision match the revision the document is actually at right now?
- Match: nothing else has committed since this client last synced, so there’s nothing to reconcile apply the operation exactly as sent, and advance the revision by one.
- Mismatch: this is the case worth being precise about. It’s not that two operations literally collide on one revision number after the fact it’s that two editors both authored their operations against the same
base_revisionbecause they typed at the same moment, before either had heard about the other. Whichever operation reaches the server first gets applied and advances the revision. The second operation then arrives carrying abase_revisionthat's now stale by exactly the number of operations that beat it there. That staleness is the trigger the server runs OT, transforming the incoming operation against every operation committed between itsbase_revisionand the document's current revision, in order — one transform per intervening operation if more than one landed first before applying the now-corrected result and advancing the revision again.
The arithmetic behind delete(2) becoming delete(3). The transform rule for an insert followed by a delete is: if the insert's position is at or before the delete's target position, shift the delete's position right by the length of whatever was inserted; otherwise, leave the delete alone entirely, since it's targeting something before the insertion and is unaffected by it. Here, A's insertion landed at position 0, inserting one character ("x"). B's delete was targeting position 2 — and 0 <= 2, so the rule applies: the delete's position shifts by the inserted length, 2 + 1 = 3. That's the entire calculation. Had B's delete instead targeted a position before A's insertion point (say, delete(0), deleting "a"), the rule's other branch would apply and it would stay delete(0) unchanged, since nothing about A's insertion at position 0 moves a character that was already at or before it. (Insert-vs-insert and delete-vs-delete pairs each have their own version of this same shift-or-leave-alone logic, tie-broken deterministically when two operations land at the exact same position — that full transform matrix is the part of OT implementations spend the most time getting exactly right.)
One detail worth naming: what gets appended to the ChangeSet log and broadcast to everyone else is the transformed operation — the corrected delete(3), not the client's original delete(2) — since the original's positions were only ever valid against a version of the document that no longer exists by the time it's actually applied.
This same routing decision is doing double duty, and it’s worth calling out explicitly: consistent hashing on document_id doesn't just give the document one place to get its revisions assigned — it also means every editor currently viewing that document has their WebSocket connection terminated on that exact same instance. That's what makes broadcasting a committed operation trivial, the instance that just committed the operation already holds every other viewer's live socket in its own local connection table so "broadcast to everyone else" is a plain in-memory loop over local sockets, not a network hop to some other server. Route two editors of the same document to two different WebSocket instances instead, and you'd need a cross-instance pub/sub layer (Redis pub/sub, a message bus) just to get one editor's keystroke to another editor's socket — an entire extra hop and an extra piece of infrastructure that same-document co-location makes unnecessary.
Routing every edit through one server per document is what makes this tractable — each operation only ever needs to be transformed against a single linear sequence the server hands it, not against every other client’s independent view of the world.
3) Users should be able to view each other’s changes in real-time.
When the Document is Loaded
Loading a document is a three-step sequence, and the order matters — get it wrong and a client either flashes stale content or double-applies an edit.
- Fetch the snapshot first. The client calls
GET /api/documents/{docId}; the Document Service returns a URL to the latest snapshot in S3 (or CDN) along with the revision it was taken at say, revision 4800 and the client fetches the actual text from that URL. This is a plain REST call, no WebSocket is involved yet, and the client already has something to render the instant this returns. - Then open the WebSocket connection. Only after the snapshot is in hand does the client open a persistent connection to the WebSocket Service, scoped to that
document_idand declaring the revision it already has (4800). - Catch up on everything since the snapshot. The WebSocket Service reads every
ChangeSetrow in Cassandra for that document with a revision greater than 4800 — say the document is actually at revision 4821 by now — and sends that backlog of operations down to the client. The client applies them, in order on top of the snapshot it already rendered, arriving at the true current text before anything else happens.
Only once that catch-up backlog is fully applied does the client start treating further messages on the socket as live, current-time broadcasts. This ordering is what prevents a race between “the snapshot as of 4800” and “edits that happened between 4800 and now” the client never has a window where it thinks it’s current but is actually missing 21 revisions’ worth of edits.
When Updates Happen
The moment the WebSocket Service commits an operation assigns it a revision and appends it as a ChangeSet row to Cassandra it broadcasts that operation down the socket of every other client currently viewing the document. Because every editor of a given document was routed to this same instance in the first place, that broadcast is just an in-memory iteration over the sockets this instance already holds open no lookup, no message bus, no cross-server hop. Because the Cassandra write happens before the broadcast, nothing gets shown to a collaborator that isn't already durable.
4) Users should be able to see the cursor position and presence of other users.
Presence (cursor position, current selection) rides the same WebSocket connection as edits, but it’s treated completely differently, it’s never written to the durable op log. It’s broadcast live and simply replaced by the next update losing one cursor update costs nothing, since the very next keystroke or mouse move corrects it. The one subtlety a cursor position still has to be transformed against incoming operations the same way edit positions are, or another editor’s cursor visibly drifts to the wrong character the instant someone types above it.
Potential Deep Dives
1) How do we scale to millions of websocket connections?
A single server can realistically hold on the order of tens to a hundred thousand concurrent WebSocket connections before it becomes a resource bottleneck (file descriptors, memory per connection, CPU for message dispatch). At millions of concurrent editors, that means a fleet of stateless gateway servers sitting in front of the actual session servers, each holding a slice of the total connections behind a load balancer.
The gateway fleet is deliberately kept separate from the per-document session servers doing OT and ordering: gateways just terminate WebSocket connections and forward messages, so they scale horizontally by adding boxes with no coordination needed between them. A client’s connection lands on whichever gateway the load balancer assigns; that gateway then forwards the client’s operations to the correct session server for that document (again via consistent hashing on document_id), and relays broadcasts back down. Because documents shard independently, the total connection count scales by adding gateways, while the total number of concurrently-edited documents scales by adding session servers — the two scale independently of each other, which is what actually gets you to millions of connections without funneling everything through one tier.
The one thing that doesn’t scale this way is a single hot document with an unusually large number of simultaneous editors, since all of its edits still funnel through one session server. Coalescing rapid-fire keystrokes into fewer broadcast messages, and capping how many editors get full interactive access versus a slightly-delayed read-only view, are the practical relief valves once a specific document gets hot enough to matter.
2) How do we keep storage under control?
Every committed operation gets appended to that document’s ChangeSet table in Cassandra, forever, if nothing bounds it — and a document that's been edited for months accumulates a lot of small rows. Two mechanisms keep this in check, and in a concrete implementation they're deliberately owned by different components than the WebSocket Service itself.
Snapshotting via a separate Cron worker. Rather than having the latency-sensitive WebSocket Service pause to write a large blob, a periodic Cron worker — decoupled from the live editing path entirely — reads a document’s recent ChangeSet rows out of Cassandra, replays them against the last snapshot, and writes the resulting full text as a new object to S3, keyed by its own revision (e.g. doc_123/rev_4821.txt) rather than overwriting the previous snapshot in place. Once that write is confirmed, the Cron worker updates the Document metadata row in Cassandra to point at this new object and its revision — that pointer flip is the actual "publish" moment, and it's what a subsequent GET /api/documents/{docId} returns from that point on. Only after the pointer has moved does deleting the now-superseded ChangeSet rows become safe (see "Deleting superseded changesets" below) — keeping the old snapshot object around briefly rather than overwriting it means a failure between the S3 write and the Cassandra pointer update still leaves the previous, still-valid snapshot fully intact and servable. Opening a document or recovering from a crash then means the Document Service fetching whichever S3 snapshot the current pointer names and replaying only the small number of Cassandra rows committed after it, not the document's entire history. Keeping this off the WebSocket Service's own request path matters: a live edit should never be blocked behind a multi-megabyte S3 write.
Deleting superseded changesets. Once the Cron worker’s new snapshot is published — meaning both the S3 object is written and the Document pointer in Cassandra has been flipped to reference it — the ChangeSet rows it just folded in are no longer needed to reconstruct current state — they're deleted from Cassandra outright, not just moved to cold storage, keeping a document's Cassandra partition bounded in size regardless of how old or how heavily-edited it is. The ordering here is the part worth being careful about, and it's now three steps, not two: write the new S3 object, flip the pointer, then delete the superseded rows — never delete before the pointer has actually moved. Deleting first would leave a gap — a crash in between means the operations that produced the new state are gone from Cassandra and no pointer yet refers to the new S3 object either, an unrecoverable loss. Publish-then-delete, in that order, is what keeps a crash mid-cycle merely wasteful (redo the same cron run against the still-valid old pointer) rather than destructive.
The tradeoff to name explicitly: running the Cron worker more frequently means faster reopen and recovery, at the cost of more S3 write volume and more Cassandra scan/delete work per run, so the exact cadence is a real dial to tune rather than a fixed constant. A reasonable default is hourly, but it should be a configurable interval rather than hardcoded — a heavily-edited document accumulating a large ChangeSet backlog might warrant snapshotting more often (or being triggered directly once its row count crosses a threshold, rather than waiting on the clock), while a quiet, rarely-touched document doesn't need hourly runs spent on it at all.
A CDN in front of S3. Because a snapshot is a large, mostly-static blob that many viewers of a popular or read-heavy document might fetch, putting a CDN in front of S3 for document reads is a natural addition — the Document Service (or the client directly, for a public/read-only document) can fetch a cached snapshot from the CDN’s edge instead of hitting S3 on every load, with the small number of post-snapshot operations from Cassandra replayed on top regardless of where the snapshot itself came from.
3) How would CRDTs solve the same problem, and how do they actually work?
Everything so far routes every edit through one WebSocket Service instance per document, using that single point of ordering to make Operational Transformation tractable. CRDTs (Conflict-free Replicated Data Types) solve convergence a completely different way: instead of relying on one server to impose an order and rewrite operations against it, every operation is built so that applying a set of operations in any order produces the same result no serializer needed at all.
The core mechanism: stable, unique identifiers instead of numeric positions. OT operations say “insert at position 12” a number that’s only meaningful relative to one specific version of the text, which is exactly why it needs transforming when the text has changed underneath it. A sequence CRDT instead gives every single character a unique, immutable identifier the moment it’s inserted, and every operation refers to identifiers, never numeric positions. A common construction (the family that algorithms like RGA — Replicated Growable Array — and Yjs’s YATA belong to) assigns each character an id built from (replica_id, logical_clock) — a per-editor counter plus that editor's own id, which guarantees every id in the whole system is globally unique even though editors are creating ids concurrently with no coordination. An insertion is expressed as "insert character C with new id id_c, immediately after the character with id id_a" not "insert at position 12." A deletion is expressed as "mark the character with id id_x as deleted" not "delete position 5."
Walking the earlier example with this scheme. The document "abc" has characters with ids, say, id_a, id_b, id_c. Editor A's insertion becomes "insert x with id id_x, before id_a." Editor B's deletion becomes "mark id_c as deleted" — not "delete position 2." Notice B's operation never referenced a position at all, so there is nothing for A's concurrent insertion to invalidate. Applying A's operation first, then B's, or B's first, then A's, produces the identical result either way: id_c ends up marked deleted regardless of when id_x was inserted relative to it, because B's operation was never expressed in terms of where id_c happened to sit in the sequence. That's what "commute" means concretely two operations can be applied in either order, or even received out of order over the network, and the outcome doesn't depend on which order they actually arrive in.
Why deletions become tombstones instead of actual removal. If id_c were physically removed from the sequence the instant it's deleted, a concurrent operation that says "insert after id_c" (issued by an editor who hadn't yet heard about the deletion) would have nothing to attach to. Instead, deleted characters stay in the underlying structure as tombstones marked invisible for rendering, but still present as valid attachment points for any operation still in flight that references them. A tombstone can only be safely garbage-collected once every replica in the system is known to have seen its deletion a property usually called causal stability which means tombstone cleanup lags behind the edits themselves and adds real bookkeeping.
What this costs, concretely. Every character now carries an id (replica id plus a counter) instead of being one byte in a plain string, which is real memory and network overhead per character rather than per operation. Tombstones accumulate until they’re safe to collect, so a heavily-edited document’s underlying representation can be considerably larger than its visible text. And the identifier-generation scheme itself has to guarantee that a new id can always be created between any two existing ids, no matter how many concurrent insertions have already happened at that spot — this is a genuinely subtle piece of the algorithm design (Logoot and Treedoc solve it with variable-precision identifier paths; RGA/YATA solve it by chaining off a causal predecessor rather than needing an id “between” two others).
Why this design doesn’t reach for a CRDT. A CRDT’s entire value proposition is convergence without a central authority. This design already has one — a per-document WebSocket Service instance is required anyway for connection management, presence, and the durable Cassandra write path — so OT gets the same convergence guarantee at a much lower per-character cost, by spending that already-necessary central point of coordination on ordering. A CRDT becomes the right tool specifically when that central authority goes away entirely: true peer-to-peer editing with no server in the loop, or an offline-first client that needs to merge a long stretch of local edits against another replica with no round trip to any server at all.
Final Design
Putting it together: a client creates and loads documents through a stateless HTTP tier, then opens a persistent connection to a gateway fleet. Each document’s edits route, via consistent hashing, to one session server, which assigns revisions, runs Operational Transformation against each operation’s base_revision, appends to a durable per-document op log before acknowledging, and broadcasts the committed operation to every other connected client. Presence rides the same connection but is never persisted. Periodic snapshots bound how much of the log ever has to be replayed, and older segments compact away once a snapshot supersedes them.
- API Gateway is the one thing a client actually talks to. It routes plain REST calls (create/load a document) to the Document Service, and for the WebSocket upgrade, it hashes on
docIdto pick which WebSocket Service instance to forward the connection to — the same consistent-hashing scheme from the high-level design, now doing concrete routing work. Every client editing the same document lands on that same instance, which is precisely what keeps broadcasting a committed operation to the room a local, in-memory fan-out instead of a hop through a separate pub/sub layer. - Document Service is the stateless REST tier:
POST /api/documentsandGET /api/documents/{docId}. It never holds a live document in memory — creating a document writes metadata to Cassandra and an empty snapshot to S3; loading one returns a pointer to the latest S3 snapshot (not the content itself), with the WebSocket Service handling the catch-up replay once the client connects (see "When the Document is Loaded" above). - WebSocket Service, beyond running OT on live edits, is also what serves a newly-connected client its backlog: on connect, it reads every Cassandra
ChangeSetrow newer than the revision the client's snapshot was taken at, and streams that backlog down before treating anything further as a live broadcast. - Cassandra holds two things:
Documentmetadata, and theChangeSettable — the durable op log, one row per committed operation, shaped asaction(the operation type),msg(its payload), andindex(the revision number driving row order within a document's partition). This is the system's actual source of truth. - S3 holds full-text snapshots — large, infrequently-written blobs that don’t belong in a row store. A CDN sits in front of S3 specifically for document reads, since a snapshot is exactly the kind of large, cacheable, mostly-static object a CDN is built to serve.
- Cron worker is what actually produces those S3 snapshots: it runs independently of the live editing path, periodically replaying a document’s recent Cassandra
ChangeSetrows forward from the last snapshot and writing the new full text to S3. Once that S3 write is confirmed durable, the Cron worker deletes theChangeSetrows it just folded in — publish to S3, then delete from Cassandra, never the other order, so a crash mid-cycle is merely wasteful rather than destructive. This is kept off the WebSocket Service's own request path so a live edit is never blocked behind a large object write or a batch delete.








