Scale Interview
System DesignHard

Design Google Docs

Collaborative Document Editing System Design

Overview

A collaborative document editor lets several people type in the same document at the same time and see each other's edits — and each other's cursors — appear live, character by character. This is Google Docs, Notion, or a shared code editor. Two people can put their cursor in the same paragraph, type at once, and both end up looking at the identical document a fraction of a second later.

It is labeled "Hard" because the core problem is not storing text — it is making concurrent edits to the same region converge. If Alice inserts a word at position 10 while Bob deletes a character at position 4 at the same instant, their edits were written against different versions of the document, so applying them blindly gives two people two different documents. The system must reconcile edits that happened concurrently so that everyone ends up with exactly the same result, no keystroke is lost, and it all happens in well under a second. Layer on top of that: the edits travel over a live connection, you must show who else is here and where their cursor is, the document must persist and keep a version history, and edits made while offline must merge cleanly when the user reconnects.

In this walkthrough you'll scope the editor, size it, model the data, define the edit protocol (which is not plain REST), draw the edit and join flows, and then go deep on the heart of the problem: concurrent-edit conflict resolution, real-time sync with presence, and persistence with versioning and offline merge.

Out of scope (state it, so you don't burn time on it): the rich-text rendering and layout engine (how a paragraph is painted on screen), the diff/comment UI, deep permissions and access-control lists (we mention where they fit), and spreadsheets/formulas — those are a different data model.

Functional requirements

  • Multiple users edit one document at once. Several people open the same document and each can insert and delete text; every edit propagates to the others in real time.
  • Everyone converges to the same document. However edits interleave, all participants (and the stored copy) end up seeing the same visible document — the same sequence of characters. No edit is silently lost. (Internal bookkeeping like tombstones or id structure may differ momentarily between replicas; what must match is the visible text.)
  • Live presence and cursors. Each user sees who else is in the document and where their cursor/selection is, updating as they move and type.
  • Persistence and version history. The document survives everyone leaving and closing their browsers, and a user can view and restore earlier versions.
  • Offline editing. A user can keep editing while disconnected; when they reconnect, their edits merge with everything that changed while they were away.

Non-functional requirements

  • Low-latency propagation. A local keystroke should appear on collaborators' screens in well under a second — target < 200 ms p99 for users in the same region. This drives the persistent-connection design.
  • Strong eventual consistency. This is the headline correctness property. Strong eventual consistency means that once every participant has received the same set of edits — in any order — they are guaranteed to hold the same visible document (the same sequence of characters), even if internal state like tombstones differs. Convergence is not best-effort; it is a property the merge algorithm must mathematically guarantee.
  • Durability — no lost edits. Once the server accepts an edit, it must survive a crash and never be dropped. We persist the edit before we treat it as committed.
  • Responsiveness under local edits. Typing must feel instant. The local editor applies your keystroke immediately (optimistically) and reconciles with the server in the background — you never wait for a round trip to see your own character.
  • Horizontal scale. Many documents, each with a handful to a few hundred live editors, plus a long tail of huge documents. The system scales by adding machines, and one popular document must not overwhelm one server.

Estimations

State assumptions; the goal is to justify the persistent-connection layer, the per-document sequencer, and the snapshot-plus-op-log store — not to be exact.

Rounded assumptions

  • Daily active users (DAU): 100M, each with a handful of documents open per day.
  • Documents edited per day: ~500M. Most have one editor; a small fraction are actively collaborative (2–50 live editors), and a rare few are large (hundreds).
  • Keystrokes are the real event rate. An active typist emits ~5 edit operations/sec. Assume ~10M users typing at peak~50M ops/sec across the platform, but each op is tiny (a single character insert/delete plus a position, ~tens of bytes).
  • Cursor/presence updates are even higher frequency than edits (every cursor move), but they are ephemeral — never persisted, and safe to drop or throttle.
  • A hot document (a company all-hands doc) might have ~200–500 concurrent editors — the fan-out case within a single document.

How the numbers affect the design

Signal from the numbers Design decision
~10M users typing at once, each on a live doc A layer of connection servers holding millions of long-lived WebSocket connections, not request/response web servers.
~50M tiny ops/sec, order matters per document A per-document sequencer that serializes edits for that one doc; scale by spreading documents across servers. The 50M/sec is spread across millions of documents, so any single per-document sequencer sees only its own doc's handful of ops/sec — the ordering constraint is per-doc, not on the global rate.
Each op is tiny but there are billions/day Store an append-only op log (cheap sequential writes), not a full re-save of the document per keystroke.
Reloading a doc must be fast even after millions of edits Periodic snapshots so a new joiner loads snapshot + a short tail of ops, not the whole history.
Cursors update constantly but don't matter after the fact Keep presence in memory, ephemeral, broadcast over pub/sub, never persisted.
One doc can have hundreds of editors Fan-out within a document is its own concern; one server owns a given live doc's session.

Storage

  • Per op: doc id (~16 bytes), author id (8 bytes), a logical position/id (~16 bytes), op type + char (a few bytes), version/sequence (8 bytes) ≈ ~50 bytes. Round to ~64 bytes with overhead.
  • The op log grows forever if untouched, which is exactly why we snapshot and compact: after a snapshot at version N, ops older than N (that no earlier reader still needs) can be garbage-collected, so the retained log stays bounded. Version history is served from periodic snapshots plus the op ranges between them, not from an unbounded per-keystroke log.

Caution

Do not model an edit as "load the whole document, apply the change, save the whole document back." At ~50M ops/sec that re-writes the entire document on every keystroke and, worse, makes two concurrent saves a read-modify-write race where the last save wins and the other person's edits vanish. The unit of change is a small operation, appended to a log, not a full-document overwrite.

Core entities / data model

Entity Key fields
Document doc_id (PK), title, owner_id, current_version, created_at
Operation doc_id, version (per-doc sequence), author_id, type (insert/delete), position, char, client_op_id
Snapshot doc_id, version, content (materialized document at that version), created_at
Membership (doc_id, user_id), role (owner/editor/viewer) — who may open the doc (permissions live here)
PresenceState (ephemeral, not persisted) doc_id{ user_id → cursor position, selection range, color, last_seen }

The heavy, always-growing entity is the Operation log: an append-only, per-document ordered list of every edit. Each op carries the version it was assigned by the server (a per-document sequence number) and a client_op_id (a client-generated unique id used to deduplicate retries and to let a client recognize its own op echoed back). The log is partitioned by doc_id, so all edits to one document live and order together on one partition.

A Snapshot is a materialized copy of the document at a specific version. It exists so a new joiner (or a crashed server) can rebuild current state as snapshot + the tail of ops after it, instead of replaying millions of operations from version 0. Membership is where permissions/ACLs live — we keep it shallow here, but it is the gate for who can open or edit.

Note

Separate the durable op log + snapshots (the source of truth, partitioned by document) from the live session state (which server currently hosts this doc, who is connected, where their cursors are). The first answers "what is the document and how did it get here"; the second is ephemeral runtime state that a server rebuilds from the log when it takes ownership of a doc.

Client contract

A collaborative editor is not a normal REST API. Loading a document and listing history are ordinary request/response calls, but editing cannot be request/response: you don't send a whole new document, you send a stream of tiny operations, and you must receive other people's operations at any moment without asking for them. So the heart of the contract is a WebSocket protocol carrying operations and presence, described explicitly alongside the few REST endpoints.

Edit channel (persistent connection)

Client opens a WebSocket:  wss://docs.example.com/connect?doc=<doc_id>
  → authenticates (token), server checks Membership, then keeps the connection OPEN

Client → server events:
  { "type": "op",       "client_op_id": "<uuid>", "base_version": 1408, "op": { "kind": "insert", "pos": 42, "char": "h" } }
  { "type": "cursor",   "pos": 43, "selection": [43, 47] }     (presence — ephemeral, high frequency)
  { "type": "ack_recv", "version": 1409 }                       (client confirms it applied up to this version)
  { "type": "ping" }                                            (heartbeat)

Server → client events (pushed, no request):
  { "type": "op",       "version": 1409, "author_id": "...", "op": { ...transformed op... } }
  { "type": "ack",      "client_op_id": "<uuid>", "version": 1409 }   (your op committed at this version)
  { "type": "presence", "user_id": "...", "pos": 43, "selection": [43,47], "color": "#e91e63" }
  { "type": "presence_leave", "user_id": "..." }

The client opens one WebSocket per open document and keeps it open. It sends each keystroke as an op tagged with the base_version it was typed against (the document version the client had seen when it made the edit) — the server needs this to transform the op against anything concurrent. The server pushes back every collaborator's committed op, an ack for the client's own ops, and high-frequency presence events. Presence events are broadcast but never persisted; a ping heartbeat keeps the connection alive and lets the server detect a dead one.

Load a document (join)

GET /api/docs/{doc_id}
Returns: 200 {
  "doc_id": "...",
  "snapshot": { "version": 1400, "content": "...full text at v1400..." },
  "tail_ops": [ ... ops 1401..1408 ... ],
  "current_version": 1408
}

Fetch the document to open it in the editor. The server returns the latest snapshot plus the tail of ops committed after it, so the client materializes current state as snapshot + tail without replaying the whole history. The client then opens the WebSocket starting from current_version.

List version history

GET /api/docs/{doc_id}/versions
Returns: 200 { "versions": [ { "version": 1400, "created_at": "...", "label": "snapshot" }, ... ] }

Show the version-history timeline. Each entry is a point the document can be restored to; restoring materializes that version from the nearest snapshot plus the op range up to it.

Create a document

POST /api/docs
Body:    { "title": "Q3 Planning" }
Returns: 201 { "doc_id": "...", "current_version": 0 }

Create an empty document and its first (empty) snapshot, then open the edit channel against it.

High-level architecture

Two flows matter: an edit propagating from one user to everyone else (the real-time path), and a user joining/loading a document (the load path). The components are shared across them:

  • Connection servers (the gateway layer). Hold the long-lived WebSocket connections and shuttle ops and presence events to and from clients. Dumb pipes — they carry events but don't own document correctness.
  • Document session server (the per-doc sequencer). For each live document, exactly one server owns the authoritative session: it assigns each op the next version, transforms/merges concurrent ops (see Deep Dive 1), broadcasts the result, and holds the in-memory presence for that doc. This is the single serialization point that makes convergence tractable.
  • Session routing registry. A fast lookup (e.g. Redis) mapping doc_id → which session server currently owns it, updated as docs are opened and servers come and go — the same idea as chat-app's connection registry, but keyed by document.
  • Op log + snapshot store. Durable, append-only op log partitioned by doc_id, plus periodic snapshots. The source of truth.

1. Edit propagation flow

Alice types a character; it must reach Bob (and everyone else in the doc) transformed so both converge.

flowchart LR
    Alice["Alice's editor"] -->|"op (base_version, insert)"| GWA["Connection Server A"]
    GWA -->|"forward op"| Seq["Doc Session Server (sequencer)"]
    Seq -->|"transform vs concurrent ops"| Seq
    Seq -->|"assign version, append"| Log[("Op Log - by doc_id")]
    Seq -->|"ack (version)"| GWA
    GWA -->|"ack"| Alice
    Seq -->|"broadcast committed op"| GWB["Connection Server B"]
    GWB -->|"push op"| Bob["Bob's editor"]
  1. Alice's editor applies her keystroke locally and immediately (optimistic UI), then sends the op over its WebSocket to Connection Server A, tagged with the base_version it was typed against.
  2. Server A forwards it to the document session server that owns this doc (found via the routing registry).
  3. The session server transforms Alice's op against any concurrent ops it has already committed since her base_version (Deep Dive 1), so the op now applies correctly to the current document.
  4. It assigns the op the next version, appends it to the durable op log (before acknowledging, so an accepted edit is never lost), and acks Alice with the committed version.
  5. It broadcasts the committed op to every other connected client through their connection servers; Bob's editor receives it and applies it (transforming it against Bob's own un-acked local ops), and both converge.

Note

The session server is the single point that orders and transforms edits for one document. That one-writer-per-document design is what turns a hard distributed-agreement problem into a tractable one: concurrency only has to be resolved at one place, not negotiated between clients.

2. Load / join-document flow

A user opening a document must reconstruct current state quickly, even after millions of edits, then start receiving live ops.

flowchart LR
    Client["Client (opening doc)"] -->|"1. GET /api/docs/id"| Doc["Document Service"]
    Doc -->|"2. latest snapshot + tail ops"| Store[("Snapshot + Op Log")]
    Store -->|"3. snapshot v1400 + ops 1401-1408"| Client
    Client -->|"4. open WebSocket from v1408"| GW["Connection Server"]
    GW -->|"5. attach to doc session"| Seq["Doc Session Server"]
    Seq -.->|"6. live ops + presence from v1409 on"| Client
  1. The client requests the document over REST.
  2. The document service reads the latest snapshot and the tail of ops committed after it.
  3. It returns snapshot v1400 plus ops 1401–1408; the client materializes the current document by applying the tail to the snapshot — a bounded amount of work, not a full-history replay.
  4. The client opens its WebSocket, declaring the version it has reached (1408).
  5. The connection server attaches the client to the document's session server (creating the session, and loading it from the store, if this is the first editor).
  6. From then on the session pushes every new committed op (1409 onward) and all presence events live; the client is now a full participant.

Tip

Lead with the single-editor happy path (one person typing, ops appended in order), then introduce a second concurrent editor to motivate transformation, then presence, then offline. Jumping straight to "two people edit the same character" without first establishing the sequencer confuses the interviewer about what problem you're solving.

3. Combined architecture

Putting the paths together:

flowchart LR
    Alice["Alice"] --> GWA["Connection Server A"]
    Bob["Bob"] --> GWB["Connection Server B"]
    GWA --> Seq["Doc Session Server (owns this doc)"]
    GWB --> Seq
    Seq <-->|"who owns doc_id"| Reg[("Session Routing Registry")]
    Seq -->|"append committed ops"| Log[("Op Log - by doc_id")]
    Seq -->|"periodic snapshot"| Snap[("Snapshot Store")]
    Seq -->|"broadcast ops + presence"| GWA
    Seq -->|"broadcast ops + presence"| GWB
    Loader["New joiner"] -->|"snapshot + tail ops"| DocSvc["Document Service"]
    DocSvc --> Snap
    DocSvc --> Log

The document session server is the hub for a live document: it sequences and transforms every op, persists it to the op log, periodically writes snapshots, and broadcasts committed ops and presence to all connected clients. The routing registry ensures every connection server forwards a given doc's ops to the one server that owns it. New joiners load state through the document service (snapshot + tail) and then attach to the same session.

Deep dives

1. Concurrent-edit conflict resolution

This is the whole problem. Two users edit the same region against different versions of the document, and both edits must survive and merge so everyone converges to the same visible document (the same sequence of characters) — the strong-eventual-consistency guarantee. The classic example:

Document: "cat"          (both users see version 5)
Alice inserts "s" at position 3  → intends "cats"
Bob   inserts "!" at position 0  → intends "!cat"

Both ops were written against version 5. If the server just applies them in
arrival order without adjustment:
  apply Alice: "cats"
  apply Bob at pos 0: "!cats"        ✓ happens to work here
But if Bob had inserted at position 2 while Alice inserted at position 1,
their raw positions now refer to DIFFERENT characters than each intended,
and the two users' documents diverge. Positions are relative to a version.

The fix is that an op cannot be applied blindly; it must be adjusted for every concurrent op that the receiver has already applied. Below are the standard approaches, worst → best.

Last-write-wins on the whole document (avoid)

Treat an edit as "here is my new full-document text," and whenever two arrive, keep the one with the later timestamp. No per-character merge at all.

Worked example:

Base: "cat"
Alice sends full text "cats" at t=1
Bob   sends full text "!cat" at t=2  (later)
Server keeps Bob's → document becomes "!cat"
→ Alice's "s" is GONE. She typed a character and it silently vanished.
  • Pro: trivial to implement — one text field, keep the newest write.
  • Con: it loses concurrent edits. Every keystroke made against a slightly stale version is discarded the moment someone else's write lands. Two people typing at once constantly stomp on each other. It violates the core requirement (no lost edits, everyone converges with all edits present).
  • Verdict: avoid. This is the anti-pattern the whole question exists to rule out — it converges (everyone ends on the same text) but only by throwing edits away.

Caution

Last-write-wins on the whole document is the single most common wrong answer here. "Converging" is not enough — the requirement is converging with every edit preserved. LWW on the full doc silently drops concurrent keystrokes. It is acceptable only for coarse, non-collaborative fields (a title), never for the collaborative body.

Operational Transformation (OT) — transform each op against concurrent ops (works, the classic approach)

Operational transformation keeps edits as small operations (insert char at pos, delete at pos) and, when an op arrives that was written against an older version, transforms it against every op that committed in between so its positions still refer to what the author meant. The server is the single serialization point: it assigns a total order to ops and transforms each incoming op against the ops it missed.

Worked example — transform an insert against a concurrent insert:

Base "cat" at v5.
Alice op A: insert "s" at pos 3   (base_version 5)
Bob   op B: insert "!" at pos 0   (base_version 5)

Server orders B first, commits it → "!cat" (v6).
Now Alice's A arrives with base_version 5, but the doc is at v6.
Transform A against B: B inserted 1 char at pos 0, which is BEFORE A's pos 3,
so shift A's position right by 1 → insert "s" at pos 4.
Apply transformed A → "!cats" (v7).

Bob, who already has "!cat", receives A transformed the same way → "!cats".
Both converge on "!cats" with BOTH edits present. Nothing lost.

The transform function encodes rules like "an insert before my position shifts me right," "a delete before me shifts me left," and tie-break rules for two inserts at the same position (e.g. order by author id) so both sides make the same choice.

  • Pro: ops are tiny and carry no per-character metadata — just a position and a char. State stays compact. It is the proven approach behind Google Docs and its predecessors.
  • Con: correct transform functions are notoriously hard to get right — the number of op-pair cases grows, and the functions must satisfy the convergence property TP1 (transforming a pair of concurrent ops is order-independent). A single-server, total-order design like ours — Jupiter or Google Wave style — transforms each client's op against one linear server history, so it only needs TP1. The much harder TP2 (transforming against a whole sequence of concurrent ops is path-independent) is required only by decentralized, multi-history OT — peer-to-peer, with no single serializer — and TP2 is exactly what most published decentralized OT algorithms historically got wrong, causing divergence bugs that are painful to reproduce. Keeping the central serializer sidesteps TP2 entirely: with one authoritative linearization there is only ever a pair to transform, never a web of concurrent histories. That is one more reason to keep the sequencer; peer-to-peer OT is extremely complex precisely because it must satisfy TP2.
  • Verdict: works, and is the classic production choice when you already have a central server ordering edits (which we do). Its cost is implementation difficulty, not runtime overhead.
CRDTs — position ids that make concurrent inserts commute (recommended)

A CRDT (conflict-free replicated data type) gives every character a globally unique, densely orderable position id instead of a numeric index. A sequence CRDT for text assigns each inserted character an id that sorts between its neighbors' ids and is unique per author, so two concurrent inserts get distinct ids and simply sort into a deterministic order — no transform against concurrent ops is needed. Deletes leave a tombstone (a marker that the character is gone) rather than removing the element, so positions never shift under a concurrent op.

Worked example — same edit, no transform:

"cat" as a CRDT: c[id=A1] a[id=A2] t[id=A3]   (ids sort A1<A2<A3)

Alice inserts "s" AFTER t: gets an id between A3 and end, e.g. Alice:7 → sorts last.
Bob   inserts "!" BEFORE c: gets an id before A1, e.g. Bob:4 → sorts first.

Every replica, whatever order it receives the two inserts in, sorts all ids:
  Bob:4("!") < A1(c) < A2(a) < A3(t) < Alice:7("s")  → "!cats"
No position adjustment, no central transform — the ids already encode order.
Concurrent inserts COMMUTE: apply in either order, same result.

Because the ids commute, replicas converge without a central server transforming anything — you can merge edits peer-to-peer or after long offline periods just by exchanging ops and sorting.

  • Pro: convergence is guaranteed by construction (the ordering is deterministic), which is exactly strong eventual consistency; no fragile transform matrix; naturally suits offline and peer-to-peer merge, since two divergent histories just union their ops.
  • Con: carries per-character metadata — each character needs a unique id (which has size), and deletes leave tombstones that accumulate, so state and memory grow and need garbage-collecting. More bytes per character than OT's bare positions. Convergence is guaranteed, but the result isn't always what a human wants: naive sequence CRDTs can interleave two users' concurrently-typed words character-by-character (e.g. "hello" and "later" merging into scrambled output) — everyone still converges, just on garbled text. Real systems use algorithms chosen partly to interleave less: RGA (Replicated Growable Array) is a common choice, while Logoot, Treedoc, and LSEQ are the position-id family that generate the densely-orderable ids described above.
  • Verdict: recommended for a new system, especially one that must handle offline/P2P merge, because convergence is guaranteed by design rather than by hand-verified transform code. You pay for it in per-character metadata and tombstone GC — a runtime cost traded for correctness you can reason about.

Choosing between them. Both give strong eventual consistency; the tradeoff is where the cost lands. OT keeps state tiny but pushes complexity into transform correctness and needs a central serializer. CRDTs move the cost to per-character metadata and tombstone GC but make convergence a structural guarantee and unlock offline/P2P. With a central server available (our design has one), either works; a modern greenfield editor increasingly picks a CRDT for the offline story and the easier correctness argument.

Warning

Whichever you choose, tie-breaking must be deterministic and identical on every replica — e.g. two concurrent inserts at the same spot ordered by author id, not by arrival time. If two replicas break a tie differently, they diverge, and "strong eventual consistency" is lost. Wall-clock timestamps are not a safe tie-breaker because clocks drift between clients.

2. Real-time sync and presence

The edit channel is a WebSocket to a connection server, and every op for one document flows to the one session server that owns that document — the authoritative sequencer. That server assigns versions, transforms/merges, and broadcasts. This mirrors chat-app's routing registry, but the routing key is the document, not the user: whichever server currently hosts a live doc's session is where all its ops converge.

Applying a remote op relative to local pending ops. A client edits optimistically — it applies your keystroke instantly and sends it, but hasn't yet heard the server commit it. Meanwhile a remote op arrives. The client can't apply the remote op as-is, because its own un-acked local ops aren't reflected in the version the remote op was committed against. So on the client:

Client has: acked doc at v40, plus 2 local un-acked ops (L1, L2) applied on top.
Server sends the ops it committed since v40 — often SEVERAL, e.g. R1 at v41, R2 at v42
(each transformed server-side against the ops before it).
Client must apply each remote op "underneath" its whole pending queue, IN ORDER:
  - OT: for each incoming R in sequence, transform R against the current pending
        queue (L1, L2), and transform the pending queue against R so it stays valid
        for when it commits — a single pairwise transform is NOT enough, because the
        server may have delivered many ops between the client's last ack and now.
  - CRDT: just merge each R's positioned op; ids commute, so L1, L2 stay valid.
Result: local view = v42 + L1' + L2', consistent with what the server will converge to.

This client-side transform (or CRDT merge) is what keeps your own in-flight typing from being clobbered by incoming edits — both are reconciled instead of one overwriting the other. Crucially, when the server has delivered a run of ops since the client's last ack, the client transforms its entire pending (un-acked) queue against that sequence of server ops in order, not with one pairwise transform.

Live cursors and presence. Presence — who is here, where each cursor and selection is — is high-frequency and ephemeral. Every cursor move is an event, far more frequent than edits, but it has zero durability requirement: if a cursor update is dropped or throttled, the next one corrects it, and nothing is lost when the user leaves. So presence is:

  • Held in memory on the session server, broadcast over the same WebSocket (or a pub/sub channel), and never written to the op log or database.
  • Throttled (e.g. coalesce cursor moves to ~10/sec) so a fast-moving mouse doesn't flood the channel.
  • Cleaned up on disconnect: when a client's heartbeat stops, the server emits presence_leave so others drop that cursor.

Warning

Never persist presence or run cursor updates through the durable op pipeline. They are the highest-frequency events in the system and the least valuable to keep; treating a cursor move like an edit (sequencing it, logging it) would multiply write load for data you throw away seconds later.

Which server owns a live doc, and reconnection. The routing registry maps doc_id → session server. The first editor to open a doc causes a server to claim ownership (load snapshot + tail into memory) and register it; subsequent editors' connection servers look up the registry and forward to that same server.

When a collaborator's connection drops and reconnects, it does not reload from scratch. It reconnects and tells the server the last version it applied; the server replays the missed ops (everything after that version) and the client transforms/merges them against any ops it made while offline. This resync-from-a-version-number is the same mechanism as the initial join, just starting from a higher version.

Note

If the session server itself dies, its in-memory session (presence, in-flight ops) is lost but the durable op log is not — a new server claims the doc, rebuilds current state from snapshot + op log, and clients reconnect and resync from their last version. The op log, not the session server's memory, is the correctness backstop.

3. Persistence, versioning, and offline

The document is not re-derived from the entire history on every load, and the op log is not kept forever unbounded. Three mechanisms make persistence scale: snapshots, version history, and offline merge.

Snapshots so loads stay cheap. Every op is appended to the durable log, but replaying millions of ops to open a document would be far too slow. So the session server periodically materializes the document into a snapshot — the full content at some version N — and stores it. A joiner loads snapshot(N) + ops(N+1 .. current), a bounded amount of work regardless of how old the document is. Snapshot cadence is a tradeoff: more frequent snapshots mean shorter tails to replay (faster loads) but more storage and snapshot-write work; a common rule is snapshot every K ops or every few minutes of activity.

Doc with 5,000,000 ops. Naive load = replay 5,000,000 ops. Too slow.
With snapshots every ~10,000 ops:
  latest snapshot at v4,995,000 (full content)
  load = snapshot + replay ~5,000 tail ops → fast, bounded.

Version history. Because the log is append-only and snapshots are periodic, version history is nearly free: to show or restore version V, materialize it from the nearest snapshot at or before V plus the op range up to V. Restoring is just "make version V the current content" (append the ops that transform current → V, or write a new snapshot). Named checkpoints ("v1: first draft") are snapshots the user chose to label.

Loading a huge document. The snapshot + tail approach caps load cost, but a genuinely huge document (a very long doc, or one with a very large current snapshot) still needs care: paginate/stream the snapshot content, and lazily load parts the user hasn't scrolled to. The op tail stays small because snapshots are frequent; the size problem shifts to the snapshot itself, handled like any large blob (chunked/streamed download).

Offline edits. A user editing offline buffers their ops locally (tagged with the base_version they branched from). On reconnect, those buffered ops must merge with everything that committed while they were away:

  • With OT: the client sends its buffered ops with their base_version; the server transforms each against all ops that landed in the meantime, assigns them versions, and commits them — and the client transforms the incoming missed ops against its own buffered ops. Long offline periods mean transforming against many concurrent ops, which is where OT's complexity bites hardest.
  • With CRDTs: the client's offline ops carry position ids that are already globally unique, so merging is just union the two sets of ops and sort by id — no transform, no matter how long the client was offline. This is the CRDT's strongest advantage: offline merge is the same commuting-merge as online, so a week-old offline branch merges as cleanly as a one-second one.

Compaction and garbage collection. The retained op log must stay bounded:

  • Op-log compaction. Once a snapshot at version N exists and no active reader still needs ops older than N, those ops can be garbage-collected — history before N is recoverable from the snapshot, so the raw ops are redundant (keep a coarser sampling of snapshots for history depth, drop the fine-grained ops).
  • Tombstone GC (for CRDTs). Deleted characters leave tombstones so concurrent ops referencing them stay valid. Tombstones accumulate forever if untouched, bloating the document's in-memory size. They can be collected once you can prove no in-flight or offline op can still reference them. The ideal correctness condition is that every replica has acknowledged past the version where the delete happened (so no straggler op can point at the tombstoned id) — but with offline clients that may never return, waiting on all replicas can stall GC indefinitely. So in practice the operative trigger is the bounded offline window (the same window the adjacent caution states): once a delete is older than the longest offline period you support, no admissible op can still reference it, and the tombstone is safe to collect. Collect too early and a late offline op references a missing id and diverges.

Caution

Do not garbage-collect ops or tombstones that an offline or lagging client might still need to merge. If a client has been offline since version 900 and you've compacted everything before version 1000, its buffered ops reference positions that no longer exist and cannot merge cleanly. GC must respect the oldest version any active/offline client could still be branched from — bound how far behind you support, and keep enough history (or a snapshot at that point) to merge them.

Tradeoffs & bottlenecks

  • OT vs CRDT. OT keeps per-op state tiny but needs a central serializer and hard-to-verify transform functions; CRDTs guarantee convergence by construction and shine offline/P2P but carry per-character id metadata and tombstone GC. Both give strong eventual consistency — the choice is where the cost lands.
  • Central sequencer vs decentralization. One session server per document makes ordering and transformation tractable, but that server is a per-document bottleneck and single point of session state. Mitigate with fast failover (rebuild from the op log) and by spreading documents across many servers. A hot document (hundreds of editors) concentrates load on its one owner — fan-out within a doc is the scaling edge.
  • Optimistic local apply vs server authority. Applying keystrokes locally before the server confirms makes typing feel instant, but the client must reconcile (transform/merge) when the authoritative order differs from what it optimistically assumed. This reconciliation is the source of most client-side complexity.
  • Snapshot cadence. Frequent snapshots make loads fast (short op tail) but cost storage and write work; infrequent snapshots save storage but slow loads and lengthen recovery. Tune per document activity.
  • Presence load vs value. Cursor/presence is the highest-frequency event and the least durable; keeping it in memory and throttling it (never persisting) is essential, or it swamps the durable pipeline.
  • Offline merge window. Supporting arbitrarily long offline edits forces you to retain history/tombstones far back, fighting compaction. Bounding the supported offline window keeps GC effective.

Extensions if asked

If the interviewer wants to push further, add only the extension that changes the design discussion (these are standalone topics; there's no dedicated guide for each yet):

  • Rich text and structured content. Bold/italic, headings, tables, embedded images. This extends the op model from "insert/delete char" to formatting ops and a tree/structured document, and both OT and CRDTs have richer variants for it.
  • Comments and suggestions (tracked changes). Anchored to positions in the document that must move as text is edited — the same transform/id machinery that keeps cursors anchored applies to comment anchors.
  • Permissions and access control (ACLs). Owner/editor/viewer/commenter roles, link sharing, and enforcing them on the edit channel (a viewer's ops are rejected). We kept this shallow; it's a real subsystem.
  • Multi-region / very large collaboration. Editors spread across regions add latency to the single sequencer; approaches include regional session servers with a reconciliation layer, which pushes you further toward CRDT-style merge.
  • Embedded objects (images, drawings). Large binary content isn't sent through the op channel; upload to blob storage and insert a reference op, like chat media.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Framing the core problem as convergence, not storage: concurrent edits to the same region must merge so everyone ends identical (strong eventual consistency), with no edit lost.
  • A concrete conflict-resolution mechanism — OT (transform each op against concurrent ops, server serializes) or CRDT (unique commuting position ids) — with the tradeoffs named correctly, not hand-waved.
  • A per-document sequencer as the single ordering/transform point, reached via a routing registry, with the op log as the durability backstop.
  • Optimistic local editing with client-side reconciliation, so typing feels instant while the server stays authoritative.
  • Snapshots + op log so loads and recovery are bounded, plus version history and compaction/tombstone GC.
  • Offline edits merging on reconnect by replaying/transforming against ops that landed while away (or CRDT union-merge).
  • Presence treated as ephemeral — in memory, throttled, never persisted.

Before you finish, do a quick mistake check:

  • Did you reject last-write-wins on the whole document and explain that it silently drops concurrent edits?
  • Did you pick OT or CRDT and get its tradeoffs right (central transform + tiny state vs commuting ids + per-char metadata/tombstones)?
  • Did you name strong eventual consistency as the property the merge must guarantee, with deterministic tie-breaking?
  • Did you use a per-document sequencer and route ops to the one server that owns the live doc?
  • Did you apply edits optimistically on the client and reconcile remote ops against local pending ops?
  • Did you persist an op log + snapshots so loads don't replay full history, and cover compaction/tombstone GC?
  • Did you handle offline edits merging on reconnect, and reconnection as resync-from-a-version?
  • Did you keep presence ephemeral and off the durable path?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →