Design Google Docs: System Design & Architecture

Build a real-time collaborative document editor where many people can type into the same document at once, see each other's edits and cursors within milliseconds, and never lose work. The hard part is not storing files (that is the Dropbox problem); it is merging everyone's keystrokes into one consistent document without conflicts.

Scope

In Scope

  • Real-time collaborative editing: many users typing into the SAME document at once, seeing each other's changes within milliseconds
  • Conflict-free merging of concurrent character-level edits, so two people typing at the same spot never corrupt the document
  • Continuous autosave: edits are sent as tiny operations as you type, persisted to a durable op log, never lost
  • Presence: live cursors and a list of who is currently in the document
  • A late-joining user can load the document's current state quickly, then receive live edits
  • Offline edits: a user keeps typing with no connection and reconciles cleanly on reconnect

Out of Scope

  • Rich media embedding, drawings, and complex page layout/pagination rendering
  • Full version history UI with named revisions and rollback (we keep the op log that makes it possible, but the browsing UI is out of scope)
  • Comments, suggestions/track-changes mode, and chat
  • Spreadsheets and slides (different data models)
  • Document sharing/permissions UI and org-wide admin controls (assume a basic access check exists)

💡 How is this different from the Dropbox question?

Dropbox stores whole files and syncs them across your devices. It deliberately skipped real-time co-editing: there, "saving" means uploading a new version of the whole file now and then, and a conflict (two devices editing offline) is resolved by just keeping both copies as "file (conflicted copy)". Google Docs is the opposite. The unit of change is not a whole file, it is a single tiny edit like "insert the letter h at position 12". Many people apply these tiny edits to the same document at the same moment, and the system must merge them into one shared document that everyone sees identically, in real time. You cannot solve that by keeping two copies, because there is only one document and everyone is looking at it live. That merging problem is the entire reason this question exists, so most of our design is about it.

Functional Requirements

  • A user can open a document and immediately see its current contents
  • A user can type, and their edits appear on every other open client within milliseconds
  • When two users edit the same spot at the same time, the system merges both edits and every client ends up showing the exact same final text
  • Every edit is saved continuously and durably as it happens, so a crash or refresh never loses more than the last fraction of a second
  • A user can see other people's cursors and selections move in real time, and who is currently in the document
  • A user who joins late gets the full current document, then a live stream of new edits
  • A user who goes offline can keep editing locally and have those edits merge in cleanly on reconnect

Non-functional Requirements

  • Edit propagation latency: a keystroke appears on other clients in well under 200ms (it should feel instant)
  • Consistency: all clients editing a document eventually converge to the exact same text (strong convergence), even though they may briefly differ for a few hundred milliseconds
  • Durability: 99.999999999% (11 nines) for accepted edits; once the server acknowledges an edit it is never lost
  • Availability: 99.9%+ for editing; a user should rarely be locked out of their own document
  • Connection model: persistent WebSocket connection per open document, not repeated polling
  • Scale target: a single popular document can have up to ~100 simultaneous editors without slowing down

💡 What does "eventually converge" mean, and why not require everyone to be identical at every instant?

Convergence means that after all the in-flight edits have been delivered, every client shows byte-for-byte the same document. We do NOT require that all clients are identical at every single instant, and here is why: you and I are in different cities, so there is unavoidable network delay between us. For a few hundred milliseconds my screen may show my latest keystroke before yours has arrived, and vice versa. That brief difference is fine and unavoidable. What we DO require is that once the edits finish flowing, we both land on the same final text, and neither of us ever sees garbled or half-merged text in the meantime. That is the "strong convergence" guarantee, and it is the whole point of the merging algorithm. On the CAP theorem: a single document lives behind one coordinator (explained later) that puts all edits for that document into one order, so per-document we lean toward consistency and accept that if that coordinator is briefly unreachable, editing that one document pauses for a moment while a replacement takes over. We are NOT trying to keep editing usable during a partition at the cost of two diverging documents, because two diverging copies of a live shared document is exactly the failure we cannot tolerate.

Back-of-the-Envelope Estimates

  • Assume 100M registered users, 50M daily active users, and 10M documents being actively edited on a busy day
  • A write is a single edit operation: one insert or delete of usually a single character, plus cursor moves
  • A read is loading a document's current contents when you open it, or receiving the live stream of others' edits
  • Active typing produces a lot of tiny writes: a fast typist generates roughly 5-10 operations/second while actively typing
  • Peak concurrent editors: assume 2M users actively typing at peak. At ~7 ops/sec each that is ~14M edit operations/second flowing through the system
  • Each operation is tiny: insert/delete of one character plus position and metadata is roughly 100-200 bytes on the wire
  • Most documents are small (a few hundred KB of text). Even a large document is a few MB, which is trivial to load compared to Dropbox's 50GB files
  • These tiny, high-frequency writes are the opposite of Dropbox: there, writes were rare and huge (whole files); here, writes are constant and tiny (single characters)

💡 What these numbers tell us about the design

14M tiny operations per second is the number that shapes everything. Each op is only ~100-200 bytes, so total bandwidth is modest (a few GB/s), but the operation RATE is enormous and every op has to be ordered correctly relative to other ops on the same document. That tells us two things. First, this is NOT a job for a CDN or blob storage. Dropbox could push huge files to a CDN because files are big, immutable, and read far more than written. Here the data is the opposite: a stream of tiny mutable edits that must be merged in order. CDNs cache static bytes; they cannot merge concurrent keystrokes. So the live path is application servers holding open connections, not edge caches. Second, the work naturally shards by document. All edits to one document must be ordered together, but edits to two different documents are completely independent. So we route every connection for a given document to one coordinator process that owns that document, and we spread the 10M active documents across many servers. One hot document with 100 editors is still only ~700 ops/sec, which one process handles easily; the scale challenge is the sheer NUMBER of documents, which we solve by horizontal sharding, not by making any single document faster. Third, durability has to keep up with the write rate cheaply. We cannot do a full database transaction per keystroke at 14M/sec. So we append edits to a durable op log (an append-only log, very cheap to append to) and only periodically fold them into a compact snapshot. That append-then-snapshot pattern is what makes continuous autosave affordable.

High-level Architecture

  • WebSocket Gateway: holds the persistent WebSocket connection for each open document tab. It authenticates the user, then routes their edit stream to the one server that owns that document. Persistent connections let the server PUSH other people's edits to you instantly, instead of you asking "anything new?" over and over
  • Document Coordinator (the heart of the system): one process owns one document at a time. Every edit for that document flows through it, so it can put all edits into a single agreed order, merge them, and broadcast the merged result to all connected clients. Sharded so that different documents live on different coordinators
  • Operation (Op) Log: a durable append-only log, one logical log per document. Every accepted edit is appended here before it is acknowledged, so nothing is ever lost. This IS the autosave
  • Snapshot Store: every so often we collapse the op log into a compact snapshot of the document's full text at a known version (compaction). A late-joining client loads the latest snapshot plus the handful of ops since it, instead of replaying millions of keystrokes
  • Presence Service: tracks who is currently in each document and where their cursors are. This data is ephemeral and lossy by design (a stale cursor is harmless), so it lives in memory / Redis with short expiry, separate from the durable op log
  • Metadata Database: document records (title, owner, permissions, current version pointer). Small and relational, read on open and on access checks

Data/User Flow Diagram

        CLIENT A (browser tab)        CLIENT B (browser tab)
              |   ^                          |   ^
   my edits   |   |  others' merged edits    |   |
   (ops)      v   |  (pushed live)           v   |
        [ WebSocket Gateway ]  <------>  [ WebSocket Gateway ]
              |                              |
              +-------------+----------------+
                            |   (all connections for doc X
                            v    routed to its one owner)
                  [ Document Coordinator for doc X ]
                  - assigns each op an order
                  - merges concurrent ops (OT)
                  - broadcasts merged op to all clients
                            |
              +-------------+--------------------+
              v                                  v
        [ Op Log (durable,            [ Presence (Redis, in-memory)
         append-only, per doc) ]       cursors + who is online ]
              |
              | (periodic compaction)
              v
        [ Snapshot Store ] <--- late joiner loads snapshot + recent ops
        [ Metadata DB ]    <--- title, owner, permissions, version

Open doc:   client -> Gateway -> Coordinator -> load latest snapshot + ops since it -> send full state -> open live stream
Type:       client -> sends op (with its base version) -> Coordinator orders + merges (OT) -> appends to Op Log -> broadcasts merged op to everyone
Reconnect:  client -> "I'm at version N" -> Coordinator sends every op after N -> client replays them, then resumes live

💡 Two terms in the diagram: WebSocket and "one coordinator owns the document"

WebSocket: a normal web page asks the server for things one request at a time, so the server can only answer when asked. That is fine for loading a page, but useless for live editing, because the server needs to PUSH your collaborator's keystroke to you the instant it happens, without you asking. A WebSocket is a connection that stays open in both directions: you send your edits up it, and the server sends everyone else's edits down it, with no repeated "anything new?" polling. This is why we use WebSockets instead of the periodic polling Dropbox used for sync; polling every 30 seconds would make co-editing feel broken. One coordinator owns the document: the reason all edits for a document go through a single process is that merging concurrent edits only works if someone decides the order they happened in. If two servers both accepted edits to the same document independently, they could order them differently and the two halves of the document would drift apart. So we make one process the single owner (the coordinator) for each document; it is the one place that decides "this edit came before that one." Different documents have different owners, so the system still scales out across millions of documents.

Data Model & Storage

  • Documents (PostgreSQL): doc_id, owner_id, title, current_version, latest_snapshot_id, created_at, updated_at. Small relational record read when a document is opened and when checking access. current_version is the op count so far and acts as the document's clock.
  • Operations / Op Log (durable append-only, per document): doc_id, version (a monotonic counter per document), author_id, op_type (insert or delete), position, char(s), client_op_id. This is the source of truth for the document's contents AND the autosave. Appended to on every keystroke; never updated in place.
  • Snapshots (Snapshot Store, blob or DB): snapshot_id, doc_id, version, full_text (the entire document text at that version), created_at. Written periodically by compaction so loading a doc does not require replaying its whole op history.
  • Presence (Redis, ephemeral, NOT durable): per doc_id, a short-lived entry per connected user with user_id, cursor_position, selection_range, color, last_seen. Expires automatically a few seconds after a user disconnects. Losing it only means cursors blink out for a moment, never lost document data.
  • Permissions (PostgreSQL): doc_id, user_id, role (viewer or editor). Checked on open to decide whether the user may read and/or send edits.

💡 Why the op log is durable but presence is throwaway, and how autosave differs from Dropbox

The op log and presence are treated completely differently on purpose, because losing them has completely different costs. The op log is the document. Every accepted edit is appended to it before we tell the client "saved", so even if the coordinator crashes one millisecond later, the edit survives and the document can be rebuilt by replaying the log. This is the autosave: there is no "Save" button and no periodic whole-document upload. Each tiny op is persisted as it happens. Contrast this with Dropbox, where saving meant occasionally uploading a fresh copy of the entire file; here we never re-send the whole document, we just append the one-character change, which is what makes saving on every keystroke affordable. Presence (cursors, who is online) is the opposite. If we lose it, a cursor disappears for a second and then reappears on the next move. Nobody loses work. Because it is high-churn (cursors move constantly) and worthless once stale, we keep it in Redis in memory with a few-second expiry instead of writing it to a durable database. Writing every cursor wiggle to disk would be a huge waste for data we are happy to throw away. Compaction: replaying millions of ops to open a document would be slow, so periodically we compute the full text at the current version and store it as a snapshot, then we no longer need the ops before it for loading (we may keep them for version history, but loading does not need them). Opening a document then means: load the latest snapshot, replay only the few ops added since it. That keeps open-time fast no matter how old or heavily-edited the document is.

API Design

GET /v1/docs/:id
Open a document: returns the latest snapshot (full text), its version number, and any ops added since the snapshot, so the client can render the current state immediately.
WS /v1/docs/:id/connect
Open the persistent WebSocket for live editing. After connecting, the client sends edit ops up and receives merged ops, presence updates, and acknowledgements down.
WS message: submit_op
Client -> server. Sends one edit op with the base version it was made against and a client_op_id for idempotency. The server merges it, assigns it a version, persists it, and broadcasts it.
WS message: ack
Server -> client. Confirms the client's op was accepted, with the version it was assigned, so the client knows it is durably saved.
WS message: remote_op
Server -> client. A merged op from another user, already transformed to apply cleanly against what this client currently has.
WS message: presence_update
Server <-> client. Cursor position / selection and who joined or left. Ephemeral, best-effort.
GET /v1/docs/:id/ops?since=N
Reconnection / catch-up: returns every op after version N in order, so a client that was briefly offline can replay and rejoin the live stream.

Low-level Design

The core problem - two people typing at the same spot:

  • Suppose the document is the word "cat" and two users both have version 5.
  • User A inserts "h" at position 0 to make "chat". User B, at the same instant, inserts "B" at position 0 to make "Bcat".
  • Both ops say "insert at position 0", and both were created against version 5, neither saw the other.
  • If the server just applies them in the order they arrive, the second one's "position 0" is now wrong, because the document already grew by one character from the first op. Naively applying both can produce "hBcat" on one client and "Bhcat" on another - the documents diverge, which is the exact failure we must prevent.
  • The fix is to TRANSFORM the second op against the first before applying it, so its position is adjusted to account for the edit that landed first. This is Operational Transformation.

Operational Transformation (OT), in plain language, using the "cat" example:

  • The coordinator owns the document, so it picks an order for the two concurrent ops: say A (insert "h" at 0) comes first, then B (insert "B" at 0).
  • It applies A: "cat" becomes "hcat".
  • Before applying B it TRANSFORMS B against A. A inserted one character at position 0, which pushed everything right by one, so B's "insert at position 0" is shifted to "insert at position 1". Applying transformed-B gives "hBcat".
  • Every client runs that same transform against the ops it had not yet seen, so client A and client B both end at "hBcat" - identical text, no divergence. The exact tie-break rule (whose insert goes first when both target position 0) just has to be the same everywhere; the coordinator deciding the order guarantees that.
  • The coordinator does the authoritative transform once and broadcasts the already-transformed op; each client then transforms it against any of its own un-acknowledged ops still in flight. That is what guarantees convergence.

Why we chose OT over CRDTs (name the tradeoff):

  • A CRDT (Conflict-free Replicated Data Type) is the other well-known approach. Instead of transforming positions, it gives every character a unique, permanent id and orders characters by those ids, so concurrent inserts merge automatically with no central coordinator and no transform step.
  • CRDTs are great for peer-to-peer or fully offline-first apps because they need no single ordering authority. Their cost is metadata: every character carries identity/ordering data, so the document and the ops are heavier, and tombstones (markers for deleted characters) accumulate.
  • We already route every document through one coordinator (we need it anyway for broadcasting and for the durable op log), so OT's requirement of a central ordering point is something we get for free, and in return our ops stay tiny (just position + character). That is why OT is the better fit here. Either answer is defensible in an interview as long as you can state this tradeoff.

Opening a document (the read/load path):

  • Client calls GET /v1/docs/:id.
  • Coordinator loads the latest snapshot (full text at some version V) from the Snapshot Store and the few ops with version > V from the op log.
  • It replays those few ops onto the snapshot to produce the exact current text and current version.
  • It returns full text + current version, and the client opens the WebSocket and is now live.
  • This is fast regardless of the document's age because we never replay the whole history, only the ops since the last snapshot.

Typing (the write/edit path, step by step):

  • 1) The user types one character; the client immediately shows it locally (optimistic, so typing feels instant) and sends a submit_op with the op, its base version, and a client_op_id.
  • 2) The coordinator transforms the op against any ops that landed after the client's base version (OT), assigns it the next version number, and appends it to the durable op log.
  • 3) Only after the append succeeds does it send an ack back to that client and broadcast the transformed op (remote_op) to every other connected client.
  • 4) Each other client transforms the incoming op against its own un-acknowledged in-flight ops and applies it.
  • 5) client_op_id makes this idempotent: if the client retries after a flaky connection, the coordinator recognizes the id and does not apply the edit twice.

Continuous autosave and compaction:

  • There is no Save button. Step 2 above - appending each op to the durable op log before acknowledging - IS the save. The most a crash can lose is an op that was typed but not yet acknowledged, a fraction of a second.
  • Appending to an append-only log is cheap, which is what lets us afford saving on every keystroke at 14M ops/sec.
  • Periodically (say every N ops or every few minutes) a background job reads the current text and writes a fresh snapshot, after which old ops are no longer needed for loading. This keeps both the op log bounded and document open-time fast.

Presence and live cursors:

  • When a user moves their cursor or selects text, the client sends a presence_update over the same WebSocket.
  • The coordinator writes it to Redis with a short expiry and broadcasts it to the other clients so they can draw that user's colored cursor.
  • Presence is best-effort: it is never written to the durable op log, and if a presence message is dropped the cursor simply updates on the next move.
  • A user's presence entry expires a few seconds after they disconnect, so "who is online" self-heals without explicit cleanup.

Offline edits and reconnection:

  • While offline, the client keeps applying the user's edits locally and queues the ops, each tagged with the base version it was made against.
  • On reconnect the client first calls GET /v1/docs/:id/ops?since=N to fetch every op it missed, replays them to catch up, then submits its queued ops.
  • The coordinator transforms each queued op against everything that happened while the client was away (exactly the same OT mechanism as live editing) and applies them in order.
  • Because every op carries its base version and a client_op_id, nothing is lost and nothing is double-applied. This is cleaner than Dropbox's "conflicted copy" approach precisely because OT can actually merge text edits, whereas arbitrary binary files could not be merged.

Scaling and coordinator failover:

  • Documents are sharded across many coordinators; a routing layer maps doc_id to its current owning coordinator so every connection for that document lands on the same one.
  • If a coordinator crashes, a new one is assigned the document and rebuilds its state by loading the latest snapshot and replaying the ops after it from the durable op log - the same load path as opening a document - then clients reconnect and resume from their last known version.
  • No edit is lost in failover because acks are only sent after the op is in the durable log.

Database Types and Schemas

Database Types and Schemas

- Documents (PostgreSQL)
  doc_id UUID PRIMARY KEY,
  owner_id UUID NOT NULL,
  title VARCHAR(255) NOT NULL,
  current_version BIGINT NOT NULL DEFAULT 0, -- = number of ops applied; the doc's clock
  latest_snapshot_id UUID,                   -- points at the newest snapshot
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,

  INDEX idx_docs_owner ON (owner_id)

- Operations / Op Log (durable, append-only, partitioned by doc_id)
  doc_id UUID NOT NULL,
  version BIGINT NOT NULL,          -- monotonic per document; the agreed order
  author_id UUID NOT NULL,
  op_type op_enum NOT NULL,         -- 'insert' | 'delete'
  position INTEGER NOT NULL,        -- where in the text
  content TEXT,                     -- inserted character(s); NULL for delete
  length INTEGER,                   -- chars deleted; NULL for insert
  client_op_id VARCHAR(64) NOT NULL,-- idempotency: dedupes client retries
  created_at TIMESTAMP NOT NULL,

  PRIMARY KEY (doc_id, version),    -- ops are read in version order: "WHERE doc_id=? AND version > N"
  UNIQUE (doc_id, client_op_id)     -- a retried op can never be applied twice

- Snapshots (Snapshot Store: blob storage or DB)
  snapshot_id UUID PRIMARY KEY,
  doc_id UUID NOT NULL,
  version BIGINT NOT NULL,          -- the document version this snapshot represents
  full_text TEXT NOT NULL,          -- entire document text at 'version'
  created_at TIMESTAMP NOT NULL,

  INDEX idx_snap_doc ON (doc_id, version DESC) -- "latest snapshot for this doc"

- Presence (Redis, ephemeral; NOT a source of truth)
  Key: "presence:{doc_id}:{user_id}"
  Value: { "cursor": 142, "selection": [142, 150], "color": "#4F86C6", "name": "Ada" }
  TTL: ~10 seconds, refreshed on every cursor move
  -- if Redis is lost, cursors just blink and re-appear on next move; no document data lost

- Permissions (PostgreSQL)
  doc_id UUID NOT NULL,
  user_id UUID NOT NULL,
  role role_enum NOT NULL,          -- 'viewer' | 'editor'
  PRIMARY KEY (doc_id, user_id)

API Request/Response Payloads

GET /v1/docs/:id (open a document)
Response
{
  "doc_id": "doc_7a2b8f4e9c1d",
  "title": "Q3 Planning",
  "version": 84213,
  "snapshot": {
    "version": 84200,
    "full_text": "Roadmap for the third quarter..."
  },
  "ops_since_snapshot": [
    { "version": 84201, "op_type": "insert", "position": 31, "content": " (draft)", "author_id": "user_aa" },
    { "version": 84202, "op_type": "delete", "position": 12, "length": 1, "author_id": "user_bb" }
  ]
}

// The client renders snapshot.full_text, replays ops_since_snapshot to reach
// version 84213, then opens the WebSocket and starts receiving live edits.
WS submit_op (client -> server: one keystroke)
Request
{
  "type": "submit_op",
  "doc_id": "doc_7a2b8f4e9c1d",
  "base_version": 84213,        // the version the client had when it made this edit
  "client_op_id": "c19f-000457",// idempotency key for safe retries
  "op": { "op_type": "insert", "position": 18, "content": "h" }
}
Response
{
  "type": "ack",
  "client_op_id": "c19f-000457",
  "version": 84214             // the order this op was assigned; now durably saved
}

// The op was transformed against anything that arrived after base_version 84213,
// appended to the durable op log, THEN acked. The ack means "saved", no Save button.
WS remote_op (server -> client: someone else typed)
Response
{
  "type": "remote_op",
  "version": 84215,
  "author_id": "user_bb",
  "op": { "op_type": "insert", "position": 19, "content": "i" }
}

// 'position' has already been transformed by the coordinator so it applies
// cleanly on top of version 84214. The receiving client further transforms it
// against any of its own ops still in flight before applying.
WS presence_update (cursors and selections, best-effort)
Request
{
  "type": "presence_update",
  "doc_id": "doc_7a2b8f4e9c1d",
  "cursor": 142,
  "selection": [142, 150]
}
Response
{
  "type": "presence_update",
  "user_id": "user_bb",
  "name": "Ada",
  "color": "#4F86C6",
  "cursor": 142,
  "selection": [142, 150]
}

// Ephemeral: never written to the durable op log. If dropped, the cursor simply
// corrects itself on the next move.
GET /v1/docs/:id/ops?since=84213 (reconnect / catch-up)
Response
{
  "doc_id": "doc_7a2b8f4e9c1d",
  "from_version": 84213,
  "to_version": 84217,
  "ops": [
    { "version": 84214, "op_type": "insert", "position": 18, "content": "h", "author_id": "user_aa" },
    { "version": 84215, "op_type": "insert", "position": 19, "content": "i", "author_id": "user_bb" }
  ]
}

// A client that was briefly offline replays these in order to catch up to the
// live version, then submits its own queued offline ops for transformation.

💡 Three terms from the design: operation, version, and transform

Operation (op): the smallest unit of change, almost always a single character. "Insert h at position 18" or "delete 1 character at position 12". We send ops, not whole documents, which is why saving on every keystroke is cheap. Version: a single counter per document that increases by one for every op the coordinator accepts. It is the document's clock and its agreed order. When a client sends an edit it includes the version it was looking at (base_version) so the coordinator knows what the client had and hadn't seen, which is exactly the information needed to transform the op correctly. Transform: adjusting an op so it still does what the user intended after some OTHER op has already changed the document. Concrete example: you mean to delete the character at position 20, but in the meantime a collaborator inserted a character at position 5. Everything after position 5 shifted right by one, so your delete must be transformed from position 20 to position 21 to still hit the character you meant. Doing this consistently on every client is what makes all copies converge to the same text.

Concluding Discussion

Potential Bottlenecks

  • A single very hot document (near the ~100-editor limit) funnels all its ops through one coordinator; the fix is that the per-document load is still small, and we cap editors and batch rapid keystrokes into fewer broadcasts
  • WebSocket fan-out: every op must be sent to every other client in the document, so a 100-person doc multiplies each keystroke 99 times; batching and debouncing presence updates keeps this manageable
  • Op log write throughput at 14M ops/sec; mitigated by partitioning the log per document and batching appends, since ops for different docs are fully independent
  • Snapshot/compaction lag: if compaction falls behind, opening a document requires replaying more ops; we trigger compaction by op count so it never grows unbounded
  • Coordinator failover pause: while a document is reassigned and rebuilt from snapshot + ops, editing that one document briefly stalls (a second or two)

Single Points of Failure

  • The coordinator for a document is a single owner by design; mitigated by fast failover that rebuilds state from the durable op log, so it is a brief pause, not data loss
  • The routing layer that maps doc_id to its coordinator must be highly available, or clients cannot find their document; run it replicated
  • The op log is the source of truth, so it must be replicated across nodes/regions; a single-region op log would risk both availability and durability
  • The WebSocket gateway tier: if a gateway dies, its clients reconnect to another gateway and resume from their last version, so it is recoverable but must be load-balanced

Security Improvements

  • Access check on open AND on every connection: a viewer-role user may receive ops but the server rejects any submit_op from them, so read-only access is enforced server-side
  • Authenticate the WebSocket on connect and bind it to the user; never trust a client-claimed author_id, stamp the author from the authenticated session
  • Validate every op server-side (position in range, sane length) so a malicious client cannot corrupt the document with out-of-bounds edits
  • Encrypt data in transit (WSS/TLS) and the op log and snapshots at rest
  • Rate-limit ops per connection to stop a single client from flooding a document with edits

Critical Knowledge

If the interviewer asks "what is the genuinely hard part here?":

  • It is merging concurrent edits to the SAME position without the document diverging.
  • Concretely: the doc is "cat" at version 5, you insert "h" at position 0 and I insert "B" at position 0 at the same instant; applied naively, different clients can end up with "hBcat" vs "Bhcat".
  • The whole design exists to prevent that: one coordinator assigns an order, transforms each op against the ops it did not see (Operational Transformation), and broadcasts the transformed op so every client converges to identical text.
  • Say this out loud early; it is the point of the question, the same way concurrent edits were explicitly OUT of scope for Dropbox.

If the interviewer asks "OT or CRDT, and why?":

  • OT transforms an op's position against other ops, and needs a central point to agree on order. CRDTs give every character a permanent id so concurrent edits merge with no coordinator, at the cost of extra per-character metadata and tombstones.
  • We pick OT because we already run one coordinator per document (for broadcasting and the durable op log), so OT's ordering authority is free, and our ops stay tiny.
  • CRDTs shine when there is no central server (peer-to-peer, offline-first). Both are correct answers; what matters is naming this tradeoff rather than reciting buzzwords.

If the interviewer asks "how is saving different from Dropbox?":

  • Dropbox saved whole files occasionally and resolved offline conflicts by keeping both copies as "conflicted copy".
  • Here there is no Save button: each tiny op is appended to a durable append-only op log before it is acknowledged, so the most you can lose is the keystroke in flight.
  • We never re-send the whole document, only the one-character change, which is what makes per-keystroke autosave affordable at scale.
  • Periodic snapshots (compaction) collapse the log so opening stays fast.

If the interviewer asks "why WebSockets instead of polling?":

  • Co-editing needs the server to PUSH your collaborator's keystroke to you the instant it happens. Polling means you repeatedly ask "anything new?", which adds latency and wastes requests.
  • A WebSocket is a persistent two-way connection: you send ops up, the server streams others' ops down with no asking.
  • Dropbox could poll every 30 seconds because file sync tolerates delay; live typing does not, so polling here would feel broken.

If the interviewer asks "how does a late joiner or a reconnecting client catch up?":

  • Opening loads the latest snapshot (full text at version V) plus the few ops after V, replayed to reach the current version, then the live WebSocket stream begins.
  • A client that dropped briefly calls GET /ops?since=N to fetch everything it missed, replays it, then submits any ops it made while offline.
  • Each op carries its base version and a client_op_id, so the coordinator transforms offline ops against what changed and the idempotency key prevents double-applying.
  • This is the same load path the coordinator itself uses to rebuild after a crash, so failover loses no edits.