System Design Interview

Collaborative Document Editing (Google Docs) System Design Interview Guide

Two people type in the same sentence at the same instant. Both screens must end up identical, and neither may wait for the network to show a keystroke.

L4, A working real-time editor L5/L6, OT vs CRDT, ownership, history L7/L8, Divergence, geo, cost of history
Two stick figures writing at separate desks while a robot between them copies each one's handwriting onto the other's page, so both pages end up identical
01

What the interviewer is testing

Most system design questions are about moving data between machines. This one is about something narrower and stranger: keeping a single mutable sequence identical on every machine while several people rewrite it at once, without ever taking a lock and without ever making a typist wait for the network.

The hard problem sits in one sentence. Every client edits a document that is already out of date by the time the edit is expressed. You typed a character at offset 42, but in the milliseconds while your keystroke was in flight, a colleague deleted a paragraph above you, and offset 42 no longer means what you meant. Applying edits in arrival order produces different documents on different screens. Applying them in timestamp order produces garbled text. Locking a paragraph produces a product nobody wants to use. The interview is asking how you get convergence (everyone ends at the same bytes) and intention preservation (the character lands next to what the user was looking at) out of a system where no client ever has a current view.

Notice what the question is not about. It is not file transfer, not chunking, not delta sync of opaque blobs. Those problems are covered in designing a file storage service, where the unit of conflict is a whole file and the acceptable resolution is "keep both copies". Here the unit of conflict is a character offset and "keep both copies" is a product failure. It is also not ordered message delivery to a room, which is the chat system question: chat messages are independent and append-only, so ordering them is enough, whereas document operations are interdependent and must be rewritten against each other before they can be applied. CRDTs make a one-line appearance in the key-value store guide as a way to reconcile two versions of one value; this guide is about the much harder case where the value is an ordered sequence and the conflict is positional.

Level Core question Differentiator
L4 Can you build an editor where two people see each other type? Persistent connections, optimistic local apply, server-assigned revisions, broadcast
L5 Can you make concurrent edits converge, and justify the mechanism? OT or CRDT with real reasoning, transform functions, snapshot + oplog, reconnect and resume
L6 Can you keep exactly one authority per document while servers die? Ownership leases, fencing, divergence detection, history compaction, bounded offline window
L7/L8 Can you evolve the transform semantics without corrupting documents? Geo ownership, versioned transforms and rollout safety, cost model of history, when to abandon OT
💡

The question that reframes the interview early: "When a user's keystroke appears on their own screen, has the server seen it yet?" If the answer is yes, you have designed a laggy editor and every other decision follows from a mistake. If the answer is no, you have just committed to clients that hold unacknowledged state, and everything interesting in this design, the transform functions, the pending buffer, the resync path, exists to manage that commitment.

02

Requirements: convergence, latency, and the offline promise

Two clarifying questions change the whole design and belong in the first minute. Is there a trusted server? If the answer is yes, a central total order is available for free and Operational Transformation becomes tractable; if the product is local-first or peer-to-peer, it is not, and §5 resolves differently. How long may a client be offline and still merge silently? That single number sets the operation retention window, which sets the compaction schedule, which sets the storage bill.

Functional requirements

Capability Why it is in scope
Concurrent rich-text editing by multiple users in one document The entire question. Everything else is a qualifier on this
Live presence: collaborator list, cursors, selections It is what makes concurrency legible to users, and it has the opposite durability requirements to edits, which is why it needs a separate path (§4)
Version history with restore Forces the operation log to be a first-class store rather than a transient buffer, and its retention policy drives §3's storage numbers
Offline editing with automatic reconciliation on reconnect The failure case that exposes whether a convergence mechanism is real or hand-waved
Sharing and per-document access control, including link sharing Permissions change mid-session, which is a live-system problem, not a settings screen (§10b)
Comments and suggestions anchored to a text range Anchors must survive edits by other people, which is a direct consumer of the convergence model and a reason OT's compact ops are convenient

Explicitly out of scope: document rendering and pagination, export to PDF/DOCX, spell check and the ML suggestion stack, and the document-list surface (search, folders, sharing UI). All are real systems; none of them touch the convergence problem, and an interviewer who wanted them would have asked for Drive.

Non-functional requirements

NFR Target Why this number
Local echo latency < 16 ms One frame at 60 Hz. A character must render in the same frame it was typed
Remote edit propagation p95 < 200 ms Below roughly 200 ms, co-editing reads as simultaneous rather than as a replay
Convergence Guaranteed, not eventual-best-effort Any two clients that have applied the same set of operations must hold byte-identical documents. There is no acceptable rate of silent divergence
Intention preservation Guaranteed for insert and delete Convergence alone is satisfiable by throwing both edits away. Text must land where the user aimed it
Durability of acknowledged operations 99.999999% (8 nines) An acknowledged keystroke that vanishes is data loss the user watched happen
Offline window for silent merge 24 hours Covers a laptop closed overnight. Beyond it, reconciliation becomes a reviewed merge, not a transform
Document open availability 99.99% ~52 minutes of unavailability per year. Editing is interactive work; an outage stops it outright
Concurrent editors per document 100, hard cap A document cannot be sharded (§3), so this is a per-machine limit surfaced as a product rule
Local echo < 16 ms — what it actually forbids ›

16 ms is one frame at 60 Hz. Any architecture in which a keystroke travels to a server and back before the character appears misses this by nearly 4×: a 30 ms one-way hop is almost two frames, and the round trip is four, so the character lands four frames after the key was pressed. That reads as lag rather than as typing. The requirement is therefore not "be fast"; it is a structural prohibition, the server round trip must not be on the rendering path at all.

That forces optimistic local application: the client mutates its own model first, renders, and only then tells the server. The consequence is the entire rest of this design. A client that has applied edits the server has not yet ordered is a client holding divergent state, and every remote operation it receives must be rewritten before it can be applied on top of that divergence.

TradeoffThe user's own edits can never be rejected outright without a visible rollback, which is jarring. The system is therefore designed so that operations are transformed rather than refused. Rendered text is therefore lost in exactly one case, and it is worth naming rather than eliding. A full resync (§10) is the common refusal and loses nothing: the client rebuilds and its pending edits become a reviewed merge. A frame rejected by server-side validation (§10b) resolves into a resync too, for the same reason. A rate-limited frame (§5b) is refused and simply retried. The exception is revocation: if access is withdrawn mid-session, the user's unacknowledged operations are discarded outright (§10b) — the one path where text the user watched appear does not come back, which is why that close is explicit and visible rather than silent.
Alternatives Server-echo rendering (simple, unusable above ~20 ms RTT) Paragraph-level locking (no transforms needed, terrible UX)
Why convergence and intention preservation are two requirements ›

A system that deletes every concurrent edit converges perfectly. A system that appends every edit to the end of the document also converges. Both are useless, which is why convergence alone is never the specification.

Intention preservation is the second half: an insert must land adjacent to the characters the author was looking at, and a delete must remove the characters the author selected, even though both were expressed against a stale offset. This is what transform functions compute, and it is why they are per-operation-pair rather than generic: the correct adjustment for insert-against-delete is not the same as for delete-against-delete.

TradeoffIntention is not always well defined. If two users select overlapping ranges and both apply bold and italic, any outcome is defensible. Systems resolve these by declaring a policy (attribute operations compose; conflicting values resolve by site order) rather than by finding a true answer.
8 nines of durability on an acknowledged operation ›

The qualifier carries the whole requirement. Operations the client has not yet had acknowledged are not covered: they live in the client's pending buffer and are re-sent on reconnect, deduped by client operation id. Operations that have been acknowledged are covered absolutely, because the acknowledgement is also what released the client's buffer.

Practically this means the acknowledgement is emitted after the operation is durable in the log, not when it is accepted into memory, which is why §6's latency budget spends 8 ms on a replicated append before anything is broadcast.

What this drives Append-only oplog as source of truth (§7) Client-side pending buffer with op-id dedup (§6) Ack-after-durable ordering in the round trip (§6)
The 24-hour offline window is a storage decision in disguise ›

Silent reconciliation works by transforming a returning client's queued operations against every operation committed while it was away. That requires those operations to still exist in untransformed, per-operation form. Compaction (§9) destroys exactly that form, replacing individual keystrokes with coarser revision groups.

So "how long may a client be offline" and "how long do we keep raw operations" are the same number. Setting it to 24 hours means raw operations must survive 24 hours before compaction, which at §3's volume is roughly 13 TB of retained raw log. Extending the promise to a week multiplies that by seven for a case that is rare and that users would accept a review screen for.

TradeoffA shorter window is cheaper but pushes more users into the reviewed-merge path, which is more visible and more annoying than a silent merge. 24 hours is chosen because it covers the dominant real case, a closed laptop overnight, and almost nothing else.
Why a hard cap on concurrent editors is an NFR and not a bug ›

Every other limit in this design can be raised by adding machines. This one cannot. A document needs a single total order over its operations, which means exactly one server accepts writes for it at any moment (§9). Adding servers does not help a hot document; it only helps you host more documents.

Worse, broadcast load inside one document grows with the square of the editor count: E editors each produce operations that must be delivered to the other E − 1. Going from 10 editors to 50 is not 5× the traffic, it is roughly 27× (§3 computes this). A cap is the only real response; Google Docs caps concurrent users at 100 — viewers included — and admits further users in view-only mode. This design separates the two, capping editors at 100 and serving viewers from replicas (§9), which is what decouples audience size from the hot-document limit.

What this drives Editor-cap rejection frame in the protocol (§5b) Replica session servers for read-only viewers (§9)
03

Capacity estimation: the hottest document, not the fleet

The usual dimensions do not bind here. Document text is tiny, so storage of the current state is uninteresting. Reads are not amplified by a social graph, so there is no fan-out-on-write explosion. What actually constrains this system is the product of three things: how many editors are connected at once, how many operations each of them emits, and, decisively, how many of them are in the same document.

The third factor is the one that shapes the section, because it behaves differently from everything else. Operations for a document must be totally ordered, so one server owns the document and every editor of it connects there (§9). A document is therefore a shard you cannot split. The fleet is sized by aggregate connections and scales horizontally without drama; the product limit is sized by whatever a single machine can do for the busiest document, and no amount of hardware changes that. So the estimate has two halves, and the interesting half is the small one.

Concurrency is derived with Little's Law: the number of sessions in flight equals the arrival rate multiplied by the average session duration (L = λW). It is the only way to get from "sessions per day", which product teams know, to "simultaneous connections", which is what you provision.

Interactive capacity estimator

100 M
3
15 min
0.4 /s
50
Concurrent editing sessions
3.13 M
open connections
300M/day × 900 s ÷ 86,400
Operations ingested
1.25 M
ops / sec
3.13M × 0.4 ops/s
Aggregate broadcast
4.25 M
messages / sec
1.25M × (4.4 − 1) peers
Busiest document, on one server
980
broadcast msgs / sec
50 × 0.4 × (50 − 1)
Session servers
63
before headroom
max(3.13M ÷ 50K sess, 1.25M ÷ 25K ops)
Raw operation log
13.0
TB / day before compaction
1.25M/s × 86,400 × 120 B
⚠

The architectural implication: 63 session servers is an unremarkable fleet, and it grows linearly with users. The number that constrains the design is the coral card. Broadcast inside one document is quadratic in editor count, E × rate × (E − 1): drag the editor slider from 10 to 50 and traffic rises roughly 27×, from 36 to 980 messages per second, all of it on a single machine that also runs every transform for that document. This is why §2 carries a hard cap as a requirement, why §9 batches broadcasts on a 50 ms frame, and why read-only viewers must be pushed off the authority onto replicas. You cannot buy your way out of a document being one shard.

ℹ

Constants behind the sliders. Concurrent editors per open document: 1.4, averaged over documents — the overwhelming majority of sessions are one person alone. Broadcast deliberately does not use that number. A document with 50 editors emits 50× the operations of a document with one, so operations are drawn disproportionately from crowded documents, and the multiplier that matters is the operation-weighted mean, E[E²]/E[E] = 4.4. For the distribution behind these defaults — 94% of open documents with one editor, 5% with five, 1% with twenty — those two means are 1.4 and 4.4, which is why aggregate broadcast comes out above ingest rather than below it. Average serialised operation size: 120 bytes (document id, revision, session id, client op id, type, position, and a short coalesced run of characters). Per session server: 50,000 attached sessions — the WebSocket itself terminates at the gateway (§4), which is sized separately on file descriptors and memory — and 25,000 ingested operations per second of transform-and-broadcast work. The session limit binds first at these defaults, which matters because it means CPU is not the scaling story. Sanity check on the op rate: 0.4 ops/s across a 15-minute session is ~360 operations per session, or roughly 1,800 characters at five characters per coalesced operation. That is an active-author profile; for a read-mostly population drag the rate to 0.1.

04

High-level architecture: a stateful authority inside a stateless fleet

The shape of this system is unusual in one respect, and the diagram reads better after naming it: there is a stateful, singular authority per document sitting in the middle of an otherwise stateless fleet. Most designs in this series push state down into a database and keep the serving tier interchangeable. Here the serving tier holds the materialised document and the current revision number in memory, because transforming an operation requires knowing every operation committed since the operation's base revision, and paying a database round trip per keystroke is not compatible with §2's propagation budget.

Architecture of a collaborative document editing service: clients connect through a WebSocket gateway to a session server that owns the document, which appends to an operation log and periodically writes snapshots, while presence, history, search and export run on separate asynchronous paths. synchronous — on the edit round trip asynchronous Editor client A Editor client B Read-only viewer Edge gateway — TLS, auth, WebSocket termination Session router doc_id → owner lease Metadata & ACL service sharing, roles, revocation Presence store cursors, field TTL 45 s Session server — document authority doc state + revision + transform engine Replica session servers viewer fan-out, no writes Operation log append-only, keyed by doc_id Snapshot store materialised every 1,000 revs Version history service Search indexer Export & render workers ops presence
Figure 1 — Solid lines are on the keystroke round trip and must fit the 200 ms budget; dashed lines may lag. Read-only viewers are routed to replica session servers rather than to the authority, which is what keeps a document with 10,000 readers from being a document with 10,000 connections to one machine.

What each component does

Editor client. Holds the full document model in memory, applies the local user's edits immediately, and keeps two buffers: the operation currently in flight to the server, and everything typed since, composed into a single pending operation. The non-obvious constraint is that it must be able to transform an incoming remote operation against both buffers before applying it — the client runs the same transform code as the server, and a mismatch between the two implementations is a divergence bug.

Edge gateway. Terminates TLS, authenticates the connection once, and holds the WebSocket. It is the only component that is genuinely stateless and horizontally trivial. The constraint is connection budget rather than CPU: at §3's defaults it is holding 3.13 M idle-ish sockets, which is a memory and file-descriptor problem, not a throughput one.

Session router. Answers one question — which session server currently owns this document — and hands out the lease that makes the answer authoritative. It is backed by a strongly consistent store, because a stale answer here does not cause a slow request, it causes two servers to accept conflicting writes. This is the component most candidates skip and the one that separates a design that works from one that only works when nothing crashes.

Session server. The document authority. It holds the materialised document, the current revision number, and a window of recent operations; it transforms each arriving operation against everything committed since that operation's base revision, appends the result to the log, and broadcasts. Its constraint is that it is the single point through which all of a document's concurrency passes, which is why §3's quadratic fan-out lands entirely here.

Replica session servers. Subscribe to the committed operation stream for hot documents and serve read-only viewers. They never accept operations, so they need no lease and no transform engine, which is precisely why they scale freely where the authority does not.

Operation log. An append-only, per-document, revision-ordered log. It is the source of truth; the document is a derived view of it. Its constraint is write amplification of a different kind than usual — the volume is small per write but enormous in aggregate (§3's 13 TB/day), and it is the reason history is tiered rather than kept.

Snapshot store. Periodically materialised document states that bound how many operations a cold open must replay. The constraint is that a snapshot must be taken at an exact revision boundary and labelled with it, or replay produces a document that is subtly wrong rather than obviously broken.

Presence store. Cursors, selections and the collaborator list, with a short TTL. It deliberately bypasses the authority and the log: presence must not be durable, must not be ordered, and must not be replayed on reconnect. Mixing it into the operation stream is a common design error that makes the log an order of magnitude larger for data nobody will ever want to read twice.

Metadata & ACL service. Owns document metadata, sharing state and permissions, and publishes revocations. The constraint is that it must be able to reach live sessions, not just the open endpoint, because a session outlives the permission that created it (§10b).

Async consumers. Version history assembly, search indexing, and export/render all read the operation log rather than querying the authority. Keeping them on the log side means a slow indexer can never add latency to a keystroke.

Architectural rationale

Why a stateful session server instead of a stateless tier over a database ›

A stateless tier would have to load the document and the recent operation window on every keystroke, or take a distributed lock per operation. The first costs a database round trip inside a 200 ms end-to-end budget that already spends 60 ms on two network hops; the second serialises every editor in the document behind a lock service and makes the lock service the thing that falls over.

Holding the document in the memory of one designated server turns each operation into a microsecond-scale in-process transform plus one durable append. The cost is that the serving tier now has affinity, which means routing must be correct (the session router), failover must be explicit (leases and rebuild-from-log, §9), and capacity must be thought about per document rather than per fleet (§3).

TradeoffAffinity buys latency and costs operational simplicity. Every failure mode in §10 that is not a client failure exists because of this choice.
Alternatives Stateless tier + per-doc distributed lock (simpler, far slower) Client-side CRDT with a dumb relay (no authority needed — see §5)
Why the router hands out a lease rather than just computing a hash ›

Consistent hashing over server identities gives every client the same answer only when every client sees the same membership list. During a deploy, a network partition, or a slow health check, two clients can hold two different views for several seconds — and in that window two servers would each believe they own the document and each assign revision 4,001 to a different operation. The documents then diverge permanently, and nothing downstream can detect which one is right.

A lease makes ownership a fact rather than a computation: a server holds document D until time T, renewed well before expiry, recorded in a store that serialises the grant. Hashing is still used, but only to decide which server should request the lease, not to decide who has it.

TradeoffLeases add a strongly-consistent dependency on the open path and a failover delay equal to the lease TTL (§9 uses 10 s). Both are accepted because the alternative failure is silent and unrecoverable.
Alternatives etcd or ZooKeeper leases A row in a strongly-consistent database (Spanner) with a fencing epoch
Why presence gets its own path and never touches the log ›

Cursor movement is higher-frequency than typing and completely worthless five seconds later. Routing it through the authority would multiply the transform engine's input rate for data that is never transformed, and appending it to the log would inflate §3's 13 TB/day several-fold with records no consumer reads.

So presence goes client → gateway → presence store, with a short TTL and a heartbeat, and is broadcast on a best-effort channel that is allowed to drop frames under load. It is also the first thing shed when a session server is over budget (§5b's back-pressure order).

TradeoffCursors can briefly be wrong or stale after a burst, which users tolerate; an edit being briefly wrong, they do not.
Why the log is the source of truth and the document is derived ›

Storing the current document text as the primary record and treating operations as a change feed is the intuitive design and the wrong one. Version history, undo across sessions, comment anchors that survive other people's edits, and rebuilding a crashed session server all need the operation sequence; none of them can be reconstructed from the final text.

Making the log primary also makes recovery trivial to reason about: any document at any revision is the fold of its operations, so a snapshot is an optimisation rather than a second source of truth that can disagree with the first.

TradeoffEvery read of a document now costs snapshot + replay rather than a single row fetch, which is why §8 spends its length on the cold-open path.
Why viewers are routed to replicas rather than to the authority ›

A published document can have thousands of simultaneous readers and two writers. Those readers need the operation stream, but they never need a revision number assigned to them, which means they do not need the total order and therefore do not need the authority.

Replicas subscribe to the committed stream and fan it out. This is the only lever that decouples audience size from the §3 hot-document limit: editors are capped at 100 because they contend for one machine, while viewers are unbounded because they contend for nothing.

What this drives Router returns a replica for role=viewer (§5b) Promotion from viewer to editor is a reconnect, not an upgrade in place

How real systems decide this

Decision This design Google Docs Figma Yjs-based editors
Convergence mechanism Server-authoritative OT Server-authoritative OT, descended from the Jupiter system and Google Wave Server-authoritative last-writer-wins per object property; Figma describes it as CRDT-inspired rather than a full CRDT, because a trusted server removes the need for one Sequence CRDT (YATA), no authority required
Who assigns the order One leased session server per document One model server per document One server per file Nobody — order derives from per-character identifiers
History model Operation log + periodic snapshots, compacted into revision groups Operation log + snapshots; the UI exposes grouped revisions, not keystrokes Operation log for undo plus periodic file versions The CRDT document is the history; deletions persist as tombstones
Offline editing 24 h silent transform, reviewed merge beyond Bounded; long-offline sessions reconcile on reopen Online-first; multiplayer assumes connectivity Unbounded by construction — merging is the algorithm
Presence transport Separate ephemeral channel, never logged Separate from the operation stream Separate, high-frequency Separate "awareness" protocol alongside the document
💡

The pattern across that table is that the convergence mechanism follows from whether a trusted authority exists, not from which algorithm is more elegant. Products with a server that must exist anyway for permissions and durability take the order for free and use OT or a server-ordered LWW scheme. Products whose value proposition is local-first or peer-to-peer cannot, and pay per-character metadata to buy an order they cannot be given. Answering "OT vs CRDT" without first asking "is there a trusted server?" is answering the wrong question — which is exactly what §5 is about.

05

Operational Transformation vs CRDTs

This is the one place in the design where two genuinely different algorithms solve the same problem, and where a candidate who names one without being able to describe the other is immediately readable as having memorised rather than understood. Both achieve convergence; they disagree about where the ordering comes from.

Neither can be described until the document is defined, because both operate on whatever position 812 refers to. The model that makes OT tractable is a flat sequence of characters with formatting carried as ranges over it rather than as nested markup — the shape Quill's delta format and Google's own wire format both use. A paragraph break, a list item, a table cell boundary: each is a single marker character in the sequence, so structure lives in the same coordinate space as text and a position is always one integer. That is what lets a transform be a few lines of arithmetic.

A genuine tree — nodes, children, moves — is where OT stops being tractable. Transforming a move against a concurrent move of an ancestor has no position arithmetic to fall back on, and the published transform functions for trees are both harder to prove correct and far rarer in production than the flat-sequence ones. Real editors keep the sequence and pay for it on the render side, rebuilding the tree on every change.

Operational Transformation puts the ordering in a server: operations are expressed against numeric positions, and a central authority rewrites each arriving operation so that it means the same thing against the current document as it did against the stale one. A sequence CRDT puts the ordering in the data: every character carries a globally unique, immutable identifier and a pointer to what it was inserted after, so any two replicas that have seen the same characters agree on their order without anyone adjudicating.

Side-by-side comparison of Operational Transformation and a sequence CRDT resolving the same pair of concurrent edits to the document ABC, both converging on XBC. ① Operational Transformation positions, rewritten by a central authority rev 5 · "A B C" Client A @rev 5 insert("X", 1) Client B @rev 5 delete(0, 1) Authority orders A then B, then rewrites each T(delete(0,1), insert(1,"X")) → delete(0,1) T(insert(1,"X"), delete(0,1)) → insert(0,"X") rev 7 · "X B C" on every client Op size ≈ 120 B. Needs one authority. Correctness lives in the transform functions. ② Sequence CRDT (RGA family) identifiers, ordered by the data itself A⟨s1,1⟩ · B⟨s1,2⟩ · C⟨s1,3⟩ Replica A X⟨a,7⟩ after ⟨s1,1⟩ Replica B tombstone ⟨s1,1⟩ Each replica applies both, in any order X sits after ⟨s1,1⟩; ⟨s1,1⟩ is hidden no transform, no central order "X B C" on every replica Per-character id metadata. Tombstones accumulate. Correctness lives in the identifier scheme.
Figure 2 — The same two concurrent edits, resolved two ways. OT rewrites the operations so positions still mean what the author intended; a CRDT never uses positions at all, so there is nothing to rewrite. Both land on "XBC". Our choice for this system is server-authoritative OT, for the reasons below — but that choice is downstream of §2's assumption that a trusted server exists.

Walking the example through OT

Start with "ABC" at revision 5, which both clients have. A inserts "X" at position 1 and immediately renders "AXBC" locally. B deletes position 0 and immediately renders "BC". Neither has seen the other. Both send their operation stamped with base revision 5.

Step What happens Resulting document
1 A's insert("X", 1) arrives first; its base revision matches, so it applies unchanged and becomes revision 6 "AXBC" on the server
2 B's delete(0, 1) arrives at base revision 5, one behind. It is transformed against A's insert: the deleted range ends at 1, which is not past the insert point, so the position is unchanged "XBC" on the server, revision 7
3 A receives B's transformed delete(0, 1) and applies it to its local "AXBC" "XBC" on A
4 B receives A's insert("X", 1) and transforms it against its own still-pending delete: the insert point sits past the deleted character, so it shifts left by one, to insert("X", 0), and applies to "BC" "XBC" on B

Two things in step 4 carry the whole design. First, the client runs the transform too — it must rewrite incoming remote operations against its own unacknowledged ones, which is why the transform code has to be shared or bit-for-bit equivalent between client and server. Second, neither user ever saw their own edit undone or re-ordered; the characters they typed stayed where they typed them, and only the other person's edit moved. That is intention preservation, and it is what distinguishes this from a merge.

The transform function, in code ›

The signature is transform(incoming, applied): rewrite incoming so it can be applied after applied, given that both were authored against the same base revision.

function transform(incoming, applied) {
  // A format changes no positions, so anything but another format passes through.
  if (applied.type === 'format' && incoming.type !== 'format') return incoming;

  // insert vs insert
  if (incoming.type === 'insert' && applied.type === 'insert') {
    if (incoming.pos < applied.pos) return incoming;
    if (incoming.pos > applied.pos) return shift(incoming, applied.text.length);
    // Same position. Break the tie with a total order on site id so that
    // both sides make the identical choice; without this, TP1 fails and
    // one client renders "XY" while the other renders "YX".
    return incoming.site < applied.site
      ? incoming
      : shift(incoming, applied.text.length);
  }

  // insert vs delete
  if (incoming.type === 'insert' && applied.type === 'delete') {
    if (incoming.pos <= applied.pos) return incoming;
    if (incoming.pos >= applied.pos + applied.len) return shift(incoming, -applied.len);
    return { ...incoming, pos: applied.pos };   // the anchor text was deleted
  }

  // delete vs insert
  if (incoming.type === 'delete' && applied.type === 'insert') {
    if (incoming.pos >= applied.pos) return shift(incoming, applied.text.length);
    if (incoming.pos + incoming.len <= applied.pos) return incoming;
    return splitAround(incoming, applied);      // the insert landed inside the range
  }

  // format vs insert: the styled range shifts if the insert lands before it and
  // splits if it lands inside, so one fmt can become two
  if (incoming.type === 'format' && applied.type === 'insert') {
    return incoming.pos >= applied.pos
      ? shift(incoming, applied.text.length)
      : splitAround(incoming, applied);
  }

  // format vs delete: clip the range to whatever survives; may become a no-op
  if (incoming.type === 'format' && applied.type === 'delete') {
    return clipRange(incoming, applied);
  }

  // format vs format: both apply unless they set the SAME attribute to different
  // values on overlapping ranges, which is the one genuine conflict here
  if (incoming.type === 'format' && applied.type === 'format') {
    return conflictsOn(incoming, applied) && incoming.site > applied.site
      ? dropAttrs(incoming, applied.attrs)
      : incoming;
  }

  // delete vs delete: subtract the overlap; the result may be a no-op
  return subtractOverlap(incoming, applied);
}

The server composes this over every operation committed since the incoming operation's base revision: for (op of log.since(incoming.baseRevision)) incoming = transform(incoming, op). That loop is why §6 caps each client at one operation in flight — it bounds how far behind a base revision can be in steady state.

TradeoffThree operation types already means nine transform pairs, and structural operations take it to sixteen — each one a place to be subtly wrong in a way that surfaces weeks later as two users disagreeing about a document. This is the real cost of OT, and §10 spends a row on detecting it.
Undo, which is a transform problem rather than a stack problem ›

Ctrl-Z in a shared document cannot pop the log, because the last committed operation is usually someone else's. Undo is per user: each session keeps a stack of the operations it authored, and undoing appends a new operation rather than removing an old one. The log is append-only, and a peer that has already applied revision 4,173 has no way to un-apply it.

The operation to append is the inverse, transformed forward: undo(op) = transform(invert(op), everything committed since op) — the same composition loop the server already runs on arriving operations. Inverting requires the log to carry enough to do it: a delete must record the characters it removed, not just a position and a length. That is a real line on §3's storage budget and the reason deletes are not the cheap operation they look like.

The interesting case is when the composed transform collapses to a no-op — you typed a word, someone deleted the region containing it, and the inverse now covers nothing. The undo succeeds and changes nothing visible, which is correct and reliably surprises users. Redo is the same mechanism applied to the inverse of the inverse.

TradeoffPer-user undo is what makes shared editing feel safe, but it is not a time machine: the document after an undo is a state it may never have been in. “Put it back how it was” is version history (§9), and conflating the two is a common miss.
How a sequence CRDT avoids the transform entirely ›

In an RGA-style CRDT, an insert is not "put X at position 1" but "put X, whose identifier is ⟨site a, counter 7⟩, immediately after the character identified ⟨site s1, 1⟩". Identifiers are never reused and never change, so the instruction means the same thing on every replica regardless of what else happened. Deletion does not remove the character; it marks it hidden, leaving a tombstone so that later inserts anchored to it still have somewhere to attach.

Concurrent inserts anchored to the same character are ordered by comparing identifiers, which is a deterministic rule rather than a negotiation. Yjs implements a variant called YATA; Automerge uses an RGA descendant. Both can merge two replicas that have never spoken to a server.

TradeoffEvery character carries an identifier, and deleted characters never leave. Real implementations claw most of this back by run-length-encoding contiguous characters from the same site and by garbage-collecting tombstones once every replica has observed them — but "once every replica has observed them" is hard to establish in an open-membership system, so the metadata is usually treated as permanent.
Families RGA / Causal Trees YATA (Yjs) Logoot / LSEQ (dense position identifiers)

Why this design picks OT

What OT buys here

  • Operations stay ~120 bytes with no per-character metadata, which is what makes §3's storage numbers survivable at all
  • The revision log is the version history, at close to the granularity the product wants to expose
  • Comment and suggestion anchors are positions that ride the same transform machinery
  • A trusted server already has to exist for permissions (§10b) and durability (§2), so the total order is free

What it costs

  • Transform correctness is on you, and errors are silent and permanent
  • Exactly one authority per document, which forces leases, fencing and failover (§9)
  • No genuine peer-to-peer or multi-master mode; geo-distribution means moving ownership, not replicating it
  • The offline window is bounded by how long raw operations are retained (§2)

The decisive point is narrower than "OT is better". Server-authoritative OT in the Jupiter model only needs transform property TP1 — that transforming two concurrent operations against each other yields the same document in either application order. Decentralised OT, where operations can arrive along different paths, additionally needs TP2, a much stronger condition about transforming against sequences of operations, and TP2 is where published OT algorithms have repeatedly been shown incorrect. Keeping a single server in the path deliberately stays inside the tractable half of OT. A CRDT would be the right answer for a local-first editor with no trusted server, for peer-to-peer sync, or for unbounded offline work — and the cost of that choice is paid in per-character metadata and coarser history.

🎯

The follow-up that always comes: "What if both users insert a character at exactly the same position at the same instant?" Positions alone cannot decide it, so the transform breaks the tie using a total order on site identifier — and the answer only counts if you add that both sides must apply the same rule. If the server tie-breaks by site id and the client tie-breaks by arrival order, the two documents differ by one character and neither side ever finds out. That is the bug §10 detects with a rolling document checksum.

05b

The editing protocol: connection, envelope, resume

The consumer of this API is not a script making requests; it is a long-lived client holding mutable state that must be reconciled after every interruption. So the interesting surface is not a list of endpoints but a connection lifecycle: what a client is told when it joins, what it may send, what it is promised about ordering, and how it recovers when the connection drops mid-edit. REST exists here only for the things that happen outside an editing session.

Joining a document

WSwss://edit.example.com/v1/doc/{doc_id}?token={session_token}
// Server's first frame, before any operation is accepted.
{
  "type": "session.init",
  "session_id": "s_7f3a91",         // identifies this connection in every later frame
  "revision": 4172,                 // the revision the snapshot below corresponds to
  "snapshot_url": "https://…/doc/d_881/snapshot/4172",
  "role": "editor",                 // editor | commenter | viewer, from the ACL
  "acl_version": 19,                // bumped on any permission change (§10b)
  "transform_version": 3,           // negotiated; see §10
  "collaborators": [
    { "session_id": "s_2b10", "user_id": "u_44", "color": "#0e7c6a" }
  ]
}

The snapshot is fetched over plain HTTP rather than streamed down the socket, because it is large, cacheable by the client, and immutable at that revision — three properties the operation channel does not have. The client applies the snapshot, sets its local revision to 4172, and only then begins sending.

The operation envelope

C→Sops — the only frame that mutates the document
{
  "type": "ops",
  "client_op_id": "s_7f3a91:118",   // idempotency key: session_id + monotonic counter
  "base_revision": 4172,            // what this client last saw
  "ops": [
    { "t": "ins", "p": 812, "s": "convergence" },
    { "t": "del", "p": 940, "n": 3 },
    { "t": "fmt", "p": 812, "n": 11, "a": { "bold": true } }
  ]
}

// Server, on success — durable before this frame is sent (§6):
{ "type": "ack", "client_op_id": "s_7f3a91:118", "revision": 4173, "checksum": "b41e…" }

// Server, to every other session on the document. The frame carries a revision
// *range*, because broadcast batching (§9) collapses several commits into one frame:
{ "type": "remote", "from_revision": 4173, "revision": 4173,
  "entries": [ { "revision": 4173, "session_id": "s_7f3a91",
                 "ops": [ /* transformed against everything committed since 4172 */ ] } ],
  "checksum": "b41e…" }

Three guarantees need stating explicitly, because an interviewer will ask for exactly these. Ordering: the server's revision number is the total order, and a client must apply remote frames contiguously: from_revision must equal its local revision plus one. A frame that skips ahead means a lost frame and triggers resume, not a best-effort apply. The range form is load-bearing rather than cosmetic — under batching a single frame carries revisions 4,174 through 4,180, and a frame naming only its highest revision would be indistinguishable from a dropped one. Each entry keeps its own session_id, because a batched frame routinely spans several authors. Delivery: at-least-once from the client, deduplicated to exactly-once by client_op_id for as long as the dedup index retains it (§7), which is what makes blind retry after a reconnect safe. Per-session FIFO: one client never has two operations in flight (§6), so its own operations can never be reordered relative to each other.

Comments and suggestions ride the same socket as a separate comment frame — create, reply, resolve — with its own ack. They are deliberately not ops frames: a comment does not change the text, so it takes no revision number and never queues behind the one-in-flight rule. Its anchor is transformed forward by the same functions the text uses (§7).

Losing and regaining the connection

C→Sresume — sent instead of a fresh join
{
  "type": "resume",
  "doc_id": "d_881",
  "last_revision": 4173,
  "pending": [ { "client_op_id": "s_7f3a91:119", "base_revision": 4173, "ops": [ … ] } ]
}

// Happy path — the gap is still inside the retained operation window:
{ "type": "resume.ok", "revision": 4190,
  "ops": [ /* revisions 4174…4190, in order */ ] }
// The client transforms its pending ops against these, then re-sends them.

// The gap has been compacted away (beyond the 24 h window of §2):
{ "type": "resync", "revision": 4190,
  "snapshot_url": "https://…/snapshot/4190",
  "reason": "revision_too_old" }
// The client rebuilds from the snapshot; its pending edits become a
// reviewed merge rather than a silent transform (§10).

Errors, limits and back-pressure

Condition Response What the client does
Token invalid or expired Close with application code 4401 Re-authenticate, then reconnect with resume
Permission revoked mid-session Close with 4403 and the new acl_version Drop to view-only or close the document; pending edits are discarded, not merged
base_revision older than the retained window resync frame with a snapshot URL Rebuild from the snapshot; surface pending edits as suggestions
Editor cap reached (100, §2) session.init with "role": "viewer" and "reason": "editor_cap" Open read-only, retry for an editor slot on a backoff
Operation above 64 KB, or rate above 20 ops/s { "type": "rate_limited", "retry_after_ms": 500 } Coalesce harder and back off; a paste above the 64 KB cap (§10b) is split client-side into several operations, which the one-in-flight rule (§6) then lands sequentially
Document ownership moving (§9) Close with 4503 after the log is flushed Reconnect immediately; resume makes the handoff invisible

Back-pressure has a defined shedding order, and stating it is what separates this from a protocol sketch. Under load a session server first drops presence frames, then coalesces remote frames into at most one per client per 50 ms, and only then begins rejecting ops with rate_limited. Edits are sacrificed last because they are the only thing that is durable.

The REST surface around the session

Endpoint Purpose Notes Level
POST /v1/docs Create a document Returns doc_id at revision 0; takes an idempotency key so a retried create does not produce two documents L4
GET /v1/docs/{id}/snapshot?revision= Fetch a materialised state Immutable at a revision, so it is long-cacheable — but private, so Cache-Control: private and never a shared CDN entry (§8) L4
GET /v1/docs/{id}/history?from=&to= List revision groups Returns compacted groups, not raw operations; granularity depends on age (§9) L5
POST /v1/docs/{id}/restore Restore an earlier version Implemented as new operations appended at the head, never as a log rewrite, so a restore is itself undoable L5
PUT /v1/docs/{id}/acl Change sharing Bumps acl_version and pushes a revocation to live sessions (§10b) L6
DELETE /v1/docs/{id}?purge=true Erase, not just trash Asynchronous: must purge the operation log and every snapshot, with a completion SLA (§10b) L7
06

The edit round trip, keystroke to remote screen

Everything the system does in steady state is one path repeated a million times a second. It is worth tracing once in full, because the ordering of two steps in the middle — durably appending before broadcasting — is the only thing standing between this design and a document that can un-type itself.

Before the trace, one rule that shapes it: a client holds at most one operation in flight. Everything typed while an operation is unacknowledged is composed into a single pending operation that goes out when the acknowledgement arrives. This is the Jupiter model, and it buys two things — the server never has to transform against an unbounded backlog from one client, and the client's state machine reduces to two buffers (sent, pending) rather than an arbitrary queue.

Sequence diagram of one edit: the author's client renders locally, sends the operation through the gateway to the session server, which transforms it, appends it durably to the operation log, then acknowledges the author and broadcasts to the peer client. Author client Edge gateway Session server Operation log Peer client apply + render ≤ 16 ms, off the path coalesce ≤ 50 ms ops @rev 4172 +30 ms network transform vs ops since 4172 +2 ms, in memory append rev 4173 durable, +8 ms ack rev 4173 + checksum — releases the pending buffer remote rev 4173 · +25 ms serialise & batch, +30 ms network transform vs pending apply + render, +10 ms Total author-keystroke to peer-screen: ~155 ms typical, 185 ms worst case, against a 200 ms p95 target.
Figure 3 — The author's own render happens before anything is sent and is not part of the budget. Note the ordering of the acknowledgement: it is emitted only after the log append is durable, because it is the acknowledgement that lets the client forget the operation.
ℹ

Propagation budget — decomposing the 200 ms p95 NFR from §2. Client coalescing window ≤50 ms · author → session server across two hops 30 ms · transform against operations committed since base revision 2 ms · durable replicated append to the operation log 8 ms · serialise, plus up to one 50 ms broadcast batching frame (§9) 5–55 ms, ~25 ms typical · session server → peer 30 ms · peer transform, apply and render 10 ms. Total ≈ 155 ms typical, 185 ms worst case, against the 200 ms p95 target. The two knobs with real slack are the coalescing window and the broadcast batch interval — together they are roughly half the budget, and neither encodes a guarantee. The two that cannot be reduced without giving one up are the durable append (§2's durability NFR) and the speed of light between the two clients.

The branches that matter

Branch When it fires What happens
Operation already seen client_op_id matches a committed operation — a retry after a flaky acknowledgement Server replies with the original ack and its revision, and appends nothing. This is what makes the client's blind-retry-on-reconnect safe
Base revision is behind Normal and constant — any document with two active editors Transform against every operation since that revision, then commit. Not an error path
Base revision is too far behind Beyond the retained raw-operation window (§2's 24 h) resync: the client rebuilds from a snapshot and its pending edits become a reviewed merge
Append rejected on a stale lease epoch This server has lost ownership and does not know it yet (§9) No acknowledgement is emitted, the server drops its sessions with 4503, and clients reconnect to the new owner and re-send
Why the broadcast waits for durability, and what the alternative buys ›

Broadcasting in parallel with the append would remove 8 ms from the budget. The cost is a window in which peers have applied an operation that the authority may never commit — if the server dies between broadcast and durable append, the operation is gone from the log but present on every screen that received it. Those clients are now permanently ahead of the source of truth, and there is no mechanism that repairs it, because the log has no record that anything is missing.

The 8 ms is therefore not a performance decision but a correctness one, and it is cheap: it is 5% of the budget, against a 50 ms coalescing window that is pure client-side policy and can be tuned instead.

TradeoffSystems that value latency over this guarantee (multiplayer games, cursors) broadcast first. Documents cannot, because the artefact outlives the session.
Why one operation in flight rather than a pipeline ›

Allowing a client to pipeline operations means the server must transform each one against a different base revision, and the client must track a queue of unacknowledged operations, each of which every incoming remote operation must be transformed against in order. Correctness is still achievable — it is just a much larger surface to be wrong on.

Composing everything typed since the last send into one pending operation costs almost nothing in latency, because the composed operation ships the instant the acknowledgement lands, and a round trip is ~70 ms while a coalescing window is already 50 ms. What it buys is a two-buffer client and a bounded transform loop on the server.

TradeoffOn a high-latency link the effective edit-send rate is capped at one round trip, so a user on a 400 ms connection ships larger, less granular operations. Version history granularity degrades with connection quality, which is an odd but acceptable coupling.
07

Data model: a log, a snapshot, and everything else somewhere else

There are only four entities that matter: the operation, the snapshot, the document (metadata and permissions) and presence. What is unusual is how differently they are used. Operations are written at a million per second and read almost never. Snapshots are written rarely and read on every cold open. Metadata is read on every open and written by humans clicking Share. Presence is written constantly and must never be persisted at all. Four access profiles that extreme do not belong in one store.

Operation Frequency (from §3) Query shape
Append one operation 1.25 M/s Append at (doc_id, revision), revision monotonic within the document
Read operations since revision R Every reconnect and every cold open — ~1,400/s Range scan within one partition, (doc_id, revision > R)
Read the latest snapshot Every cold open — ~1,400/s Point read of the highest revision for doc_id
Resolve permissions Every open and every reconnect — ~3,500/s Point read on doc_id, then group expansion
List a user's documents Roughly 10× document opens Index scan by (user_id, last_opened) — the only query that crosses documents
Read a revision range for history Rare, well under 1% of opens Nearest snapshot + range scan, then group by author and time

Two observations out of that table determine everything below. First, every hot-path access is keyed by a single doc_id and none of them crosses documents. That makes doc_id the partition key in every store on the edit path, guarantees that a document's data is co-located with its owning session server's reads, and means the document list — the one cross-document query — has no business being in the same store. Second, operations are immutable and strictly ordered, and are never updated or individually deleted. A store that only needs append and range-scan-within-partition is a log, and choosing a general-purpose table with secondary indexes for it buys flexibility nothing will use.

Operation log

document_ops
  PRIMARY KEY ((doc_id), revision)     -- partitioned by document, clustered ascending

  doc_id         uuid
  revision       bigint      -- assigned by the owning session server; gapless
  lease_epoch    bigint      -- fencing token; append rejected below the current epoch (§9)
  session_id     text
  user_id        uuid
  client_op_id   text        -- dedup key; unique with session_id
  base_revision  bigint      -- what the author saw when they typed
  ops            blob        -- compact encoding, ~120 B average (§3)
  created_at     timestamp
Why base_revision is stored even though the op is already transformed ›

The committed operation is the transformed one, so at first glance the author's original base revision is history. It is kept because it is the only record of what the author actually saw — needed to replay a transform during an investigation, to reconstruct why a document diverged, and to group operations into meaningful revision groups during compaction (edits made against the same base by the same author are one authoring act).

It is eight bytes on a 120-byte row, or ~7% of §3's 13 TB/day. That is the honest price, and it is the difference between being able to explain a divergence and not.

How client_op_id is actually looked up ›

The dedup check in §6 — "have I already committed this client_op_id?" — cannot be a query against this table. The only access path here is (doc_id, revision), so finding a client_op_id means walking the whole partition, per retried operation, on a document that may carry tens of thousands of revisions.

It is served instead from an in-memory map on the owning session server, client_op_id → revision, covering the sessions it hosts. The column earns its place by making that map rebuildable; it is written and replayed, never queried.

The rebuild window is the part worth getting right, and it is where the obvious answer is wrong. Rebuilding the map from the same snapshot-plus-tail the materialised document uses (§9) bounds it at 1,000 operations — about 50 seconds on the busiest document. A client that was appended-but-unacknowledged and reconnects after that boundary re-sends an operation the new owner can no longer recognise, and the text commits twice: at-least-once, not the exactly-once §5b claims. So the dedup set is kept as its own index, (doc_id, client_op_id) → revision, written on commit and expiring with the raw operation window of §2. Scanning 24 hours of log instead is not an option — 1.7 M operations inside a 10-second failover budget is precisely what §8's 1,000-operation bound exists to avoid. Beyond that window a resume is already a reviewed merge (§10), where dedup is not relied on.

Why the fencing epoch lives on the row rather than in a lock ›

A lease (§9) tells a server it owns a document, but a lease can expire while a request is in flight — the classic case is a garbage-collection pause longer than the lease TTL, after which the server wakes up and confidently writes. Checking the lease before writing does not close this, because the check and the write are not atomic.

Putting the epoch in the append and having the store reject anything below the highest epoch it has seen does close it: the store itself becomes the arbiter, and a stale owner's write fails rather than corrupts. No partitioned log tracks "highest epoch seen" natively, so in practice it is a current_epoch cell on a per-document control row, compared and updated under the same conditional write that appends — a Cassandra lightweight transaction, a Spanner read-write transaction, or a conditional PUT, depending on the store.

The epoch must be published to that control row when the lease is granted, not on the new owner's first append. Otherwise the store does not yet know epoch 42 exists, and a zombie at epoch 41 that wins the race to the next revision still commits once.

A cheaper variant drops the cell and makes the revision itself the detector: append with IF NOT EXISTS on (doc_id, revision), so two owners writing revision 4,173 collide. Be careful calling this a fence, because it is not one. A fence excludes every lower epoch permanently; a revision collision excludes nobody from the next slot, and it does not pick a winner — if the paused owner's append lands first, the legitimate new owner is the one rejected. It is usable only because a collision can have no benign cause: with a single writer by construction, a failed conditional append is proof that ownership has been lost, and the server must handle it as §6 already prescribes — stop accepting operations, drop its sessions with 4503, release the lease — and never rebase and retry.

TradeoffEither form is a consensus round per append rather than a blind write, which is most of §6's 8 ms durable-append budget and constrains the choice of store. It is non-negotiable — without it, §10's split-brain row has no answer.
Why revisions must be gapless, not merely increasing ›

Clients detect a lost broadcast by noticing that a frame's from_revision is not the revision after the one they hold (§5b). If revisions could legitimately skip, that detector is useless and a dropped frame becomes silent divergence — precisely the failure §2 forbids. This is also why a batched frame must carry its range rather than only its highest revision: the detector works on the lower bound.

Gapless numbering is cheap here because the owning session server is the only writer and assigns the number in memory. It would be expensive in any design with multiple writers, which is another way of saying the single-authority choice pays for itself in more than one place.

Snapshots

document_snapshot
  PRIMARY KEY ((doc_id), revision DESC)   -- latest first; point read is the common case

  body        blob        -- compressed materialised document at exactly this revision
  op_count    int         -- operations folded since the previous snapshot
  checksum    text        -- must match the rolling checksum carried on revision N (§10)
  created_at  timestamp

The checksum column is the quiet one, and it is worth being precise about what it can and cannot catch. A snapshot is produced by folding the committed operations, which is the same fold the owning session server performs in memory. A mismatch is therefore direct evidence that the authority's memory and the log have diverged: an operation broadcast but never appended, a lost or reordered append, or storage corruption — caught by a background job rather than by a user noticing their document is wrong.

What it cannot catch is a transform bug. The committed operation is already the transformed one, so a wrongly transformed operation is written once and folded identically by both sides; the checksums agree and the document is quietly wrong. Transform bugs surface only in the client comparison (§10), because a client's document is a genuinely different computation — remote operations transformed against its own pending buffer, which the server never did. The two checks are complementary, and an answer that offers only the server-side one has not covered the failure it claims to.

Metadata, permissions and presence

documents                              -- relational store, indexed for the doc picker
  doc_id, title, owner_id, created_at,
  current_revision, snapshot_revision,
  state ENUM('active','trashed','purging','purged'),
  acl_version, home_region

document_acl
  PRIMARY KEY ((doc_id), principal_id)   -- principal = user, group, or link token
  role ENUM('owner','editor','commenter','viewer'),
  granted_by, expires_at, link_scope

-- Presence is not a table. It is a Redis hash with PER-FIELD expiry:
HSET     presence:d_881 s_7f3a91 '{"u":"u_44","anchor":812,"head":830}'
HEXPIRE  presence:d_881 45 FIELDS 1 s_7f3a91   -- Redis 7.4+; refreshed by a 15 s heartbeat
EXPIRE   presence:d_881 3600                   -- key level; only reclaims fully idle documents

The per-field expiry is load-bearing, not a refinement. EXPIRE applies to the whole hash, and every live editor's heartbeat refreshes it — so on a document with two editors, one browser dying leaves its field in the hash indefinitely, kept alive forever by the survivor's heartbeat. The key-level TTL only reclaims documents that go completely idle. HEXPIRE (Redis 7.4+) is what actually makes a dead session disappear. Before 7.4 the equivalent is to carry the heartbeat timestamp inside each field's JSON and have the session server filter stale fields on read, pruning them opportunistically; a key-level TTL cannot express per-session liveness at all.

Splitting documents off into a relational store is deliberate. The document picker needs queries the edit path never issues — by owner, by folder, by last-opened, by title prefix — and those require secondary indexes that would be dead weight on a log optimised for partition-local appends. It also means a slow picker query cannot contend with an operation append, which matters more than the small cost of a second store.

⚠

The state column is doing real work. A document moves active → trashed → purging → purged, and only the last state means the content is gone from the operation log and every snapshot. Modelling delete as a boolean, or as a row removal in documents, leaves every character the user ever typed sitting in the log — which is fine for a trash can and a compliance failure for an erasure request (§10b).

Comment and suggestion anchors

A comment is attached to a range of text, and that text moves every time anyone types above it. The anchor is therefore not a pair of offsets; it is a pair of offsets plus the revision they were taken against, and the same transform functions that move operations forward move anchors forward too.

comments
  PRIMARY KEY ((doc_id), comment_id)
  author_id, body, created_at, parent_comment_id,
  anchor_start, anchor_end,              -- offsets into the document
  anchor_revision,                       -- the revision those offsets are valid against
  state ENUM('open','resolved','orphaned')

On load, the session server transforms each anchor from anchor_revision to the current revision through the log, then rewrites the row so the next load starts closer. The interesting case is when a delete covers the whole anchored range: the transformed anchor collapses to zero width, and the comment becomes orphaned rather than disappearing. Orphaned comments still render in the sidebar with their quoted text, detached from the body — which is what users expect, and what a naive offset-only anchor cannot express.

Suggestions are the same mechanism pointed at the operation log instead. A suggested edit is a real operation, tagged with a suggestion_id and committed to the log like any other, but the snapshot builder skips it: it is visible as an overlay and not folded into the materialised document until someone accepts it. Accepting appends nothing new — it clears the tag and the next snapshot includes the operation. Rejecting appends the inverse, exactly as undo does (§5). This is why suggestion mode does not need a second document or a branch: the log already orders everything, and the tag decides what the snapshot sees.

08

Opening a document: snapshots, the operation tail, and why none of it is a CDN

The usual caching section does not apply here, and saying why is more useful than forcing one. A document body is mutable and per-user authorised, which disqualifies every shared cache: an edge node cannot hold it because it would serve stale text, and it cannot hold it because the next requester may not be allowed to see it. There is exactly one shared cache in this design, and it is the session server that already owns the document.

What replaces the cache hierarchy is a read-path reconstruction problem. §7 made the operation log the source of truth, which means "open this document" is not a row fetch but a fold over history. Left alone that cost grows without bound, so the real subject of this section is how many operations an open is ever allowed to replay, and what it costs when the answer is zero.

Layer What it holds Lifetime Share of opens served
Session server memory (the owning authority) Materialised document, current revision, recent operation window While any session is open, plus a 5 minute idle grace period ~60% — a warm open, someone else already has the document open or just closed it
Snapshot store Latest materialised state per document, plus named versions Until superseded by the next snapshot Every cold open, ~1,400/s at §3's defaults
Operation log Operations committed since the latest snapshot Raw for 24 h, then compacted (§9) Every cold open replays this tail — at most 1,000 operations by construction
Edge CDN Editor bundle, fonts, and embedded images addressed by content hash Long, immutable URLs Everything except the document itself

The last row is the nuance to state out loud in an interview. Embedded images are content-addressed immutable blobs and belong on a CDN; the document text never does. Candidates who put "CDN" in front of the whole system are describing a different product; candidates who say "no CDN, it's all dynamic" have given up an easy 80% of the bytes.

The cold-open budget

ℹ

Cold open, target p95 < 500 ms. Router lookup and lease acquisition 10 ms · permission resolution 10 ms · snapshot fetch, ~60 KB compressed 25 ms · replay of at most 1,000 operations at roughly 200 K ops/s in-process 5 ms · client parse and first paint 150 ms. Total ≈ 200 ms, with the client render dominating — which is the correct outcome, because it means the server side has 300 ms of margin and the remaining optimisation work belongs to the front end. A warm open skips the snapshot fetch and the replay entirely and costs roughly 20 ms server-side; permission resolution is charged on every open and every reconnect (§7), so it is never the step that disappears.

Snapshot cadence, and why 1,000

A snapshot is written every 1,000 revisions or 30 minutes of active editing, whichever comes first. Both bounds exist because either alone fails: revisions alone leave a document edited once a day replaying a week of scattered operations, and time alone leaves a document under heavy collaborative editing accumulating 20,000 operations in half an hour.

The number itself falls out of two costs pulling in opposite directions. Replaying 1,000 operations costs about 5 ms and reads 120 KB of log — negligible, so the replay side would happily accept 10,000. The snapshot side will not: a 200 KB materialised document, stored compressed at about 60 KB, rewritten on every revision at §3's 1.25 M operations per second would be 75 GB/s of snapshot writes, which is absurd by three orders of magnitude. At one snapshot per 1,000 revisions it is 75 MB/s, which is ordinary. 1,000 is simply where the replay cost is still invisible and the write amplification has become affordable.

Invalidation, which mostly is not a problem

This is where the single-authority choice pays off in an unexpected place. Because exactly one server may write a document, and every reader of the live document is connected to that server or to a replica fed by it, there is no cache coherence problem in steady state. Nothing can be stale relative to anything else, because there is only one writer and one materialised copy.

That leaves two events that genuinely invalidate, and neither is a TTL:

Event What becomes invalid Mechanism
Ownership transfer (§9) The old owner's in-memory document, and every client's assumption about where to send Drain, then close sessions with 4503; clients reconnect and resume. Never a TTL — a TTL would mean a window where the old owner still answers
Permission change (§10b) The acl_version stamped on every live session for that document Push from the metadata service to the owning session server, which closes affected sessions with 4403
💡

The presence store is the one place a TTL is the right answer, and for the opposite reason: nobody pushes "this user's browser crashed". A 45-second per-field expiry refreshed by a 15-second heartbeat — three heartbeats of slack — means a dead session disappears from the collaborator list without anyone having to detect the death. It has to be per-field rather than per-key (§7), because a key-level TTL is refreshed by whoever is still alive. Correctness here is bounded staleness, not coherence, which is exactly when a TTL earns its place.

09

Scaling: one owner per document, and everything that follows from it

Scaling this system is not the usual exercise of adding replicas until the load fits, because the thing that binds (§3) is a single document on a single machine. The work divides into three genuinely different problems: keeping exactly one owner per document while machines fail, making the busiest document survivable on one machine, and keeping the history affordable.

Ownership: leases and fencing

A document is assigned to a session server by a lease recorded in a strongly consistent store — 10 second TTL, renewed every 3 seconds. Consistent hashing over the server pool decides which server asks for the lease; it never decides who holds it. That distinction is the whole mechanism: hashing is a local computation that different clients can disagree about during membership churn, while a lease is a fact serialised by one store.

Document ownership failover: the lease registry grants a new epoch to a second session server, and the operation log rejects appends carrying the old epoch, so a partitioned former owner cannot corrupt the document. Clients on doc d_881 Lease registry strongly consistent · TTL 10 s Session router hash decides who asks S1 — former owner paused, still holds epoch 41 S2 — new owner granted epoch 42 Operation log — conditional append reject any append whose lease_epoch < highest seen (42) ① open ② who owns d_881? ③ grant epoch 42 ④ append @41 → rejected ⑤ append @42 → committed A stale owner that wakes from a long pause cannot corrupt the document; its writes fail at the store, not at a check it performs on itself.
Figure 4 — Ownership failover with a fencing epoch. The lease alone is not sufficient, because a server can be paused for longer than its own TTL and wake up believing it is still the owner. The store having the final say is what makes split brain a rejected write rather than a divergent document.

A planned handoff — a deploy, or a rebalance — does not wait for the TTL. The outgoing owner stops accepting operations, flushes everything pending to the log, records the final revision, releases the lease, and closes its sessions with 4503. Clients reconnect, resume from their last revision against the new owner, and the whole thing costs a few hundred milliseconds during which typing is buffered locally rather than lost. An unplanned handoff costs the lease TTL instead: up to 10 seconds of buffered typing, after which the new owner rebuilds from the latest snapshot plus the log tail and clients resume against it.

Surviving the busiest document

§3 showed the problem: 50 editors on one document produce ~980 broadcast messages per second on one machine, and the relationship is quadratic. Three levers apply, cheapest first — though cheapest is very nearly the reverse of most effective:

Lever What it does What it costs
Broadcast batching on a 50 ms frame Caps each client at 20 outbound frames per second, collapsing whatever arrived inside the frame into one message. Less than its reputation suggests — see below Up to 50 ms of added propagation, which is most of §6's p95 margin — the first knob to shorten if propagation regresses
Replica fan-out for viewers Read-only sessions attach to replicas that subscribe to the committed stream, so audience size stops touching the authority at all Viewers see edits one extra hop later; promotion to editor requires a reconnect
Hard editor cap (100, §2) Bounds the quadratic term outright; further users open in view-only A visible product limit. Every real product has one

Batching deserves a caveat, because it is the lever candidates reach for first and the one that does least. At §3's defaults each editor already receives only 0.4 × 49 ≈ 20 messages per second, so a 50 ms frame barely clamps anything — it takes the coral number from 980 to roughly 625/s. The clamp engages properly only above ~51 editors, and at the 100-editor cap it is a 2× saving, 3,960 down to 2,000. It is a real lever against a burst where everyone types at once and a weak one against editor count, which is the opposite of how it is usually offered.

Keeping history affordable

13 TB/day of raw operations (§3) cannot be retained at full fidelity, and does not need to be: nobody scrubs version history keystroke by keystroke. History is therefore tiered, and each tier trades resolution for size.

Age What is retained Granularity a user sees Volume
< 24 h Every raw operation, untransformed base revision included Effectively keystroke-level 13 TB/day — and this window is exactly §2's offline promise
24 h – 30 d Revision groups: contiguous operations by one author within 5 minutes, collapsed to the resulting diff plus a boundary snapshot One authoring act ~1.3 TB/day — roughly 10×, because 5 minutes of typing is ~120 operations at 120 B (14.4 KB) that collapse to a ~1.5 KB diff
30 d – 1 y Hourly snapshots plus every named version An hour Snapshot-sized; independent of how much was typed
> 1 y Named versions and daily snapshots A day Negligible
⚠

Compaction and the offline promise are the same mechanism. A returning client can only be transformed forward while the raw operations it missed still exist. Compaction destroys exactly that form. So "we retain raw operations for 24 hours" and "you may edit offline for 24 hours" are not two policies that happen to agree — they are one number, and changing either changes both. An interviewer who asks "what if I'm offline for a week?" is asking whether you know that.

Geography

Every document has a home region, and the authority lives there. An editor in Singapore on a document homed in Iowa pays the round trip — roughly 190 ms, against §6's 30 ms per-hop assumption — which lands propagation near 285 ms. That is not catastrophic; it is a miss on the 200 ms NFR that no amount of tuning inside the region recovers. There are only two real responses, and multi-master is not one of them: OT needs a single total order, so replicating write authority across regions is not a tuning decision but a change of algorithm to a CRDT.

Follow-the-workload ownership migration ›

If the editors of a document are consistently in one region and the document is homed in another, move the document. Migration is the planned-handoff procedure from earlier in this section, plus waiting for the operation log to be replicated to the target region before the lease is granted there — otherwise the new owner rebuilds from a stale tail.

The trigger should be conservative: sustained editor concentration over hours, not a single session, because a migration has a visible reconnect and a document that oscillates between regions is worse than one that is simply far away.

TradeoffA genuinely cross-continental document — half the editors in each region — has no good home, and someone pays the latency. That is a property of requiring a total order, not a flaw in the implementation.
Edge relays cut the presence latency, not the edit latency ›

Presence has no ordering requirement (§4), so cursors can be exchanged between two editors in the same region through a local relay without ever reaching the home region. That makes the feeling of co-presence local even when the document is not.

It is a real improvement and a partial one: the cursor moves at local latency and the text it is pointing at arrives at cross-region latency, which is briefly confusing. This design ships it anyway. The alternative — holding presence back to match the text — trades a moment of confusion for a session that feels uniformly slow, and the uniform version is worse.

Evicting cold documents and rebalancing the fleet ›

A session server holds a document in memory for five minutes after the last session closes, which is what produces §8's ~60% warm-open rate — reopening a document you just closed, or joining one a colleague has open, is the common case.

Eviction is simply releasing the lease after that grace period. Rebalancing after a scale-up is the same operation applied deliberately: the router prefers the new hashing position for documents that are not currently open, so the fleet rebalances through natural document churn rather than through a migration storm. Only persistently hot documents need an explicit move, and those are few enough to handle individually.

TradeoffRebalancing through churn is slow — hours — which is fine for capacity and too slow for evacuating a failing zone. That case uses the planned-handoff path in bulk instead.
10

Failure modes: what breaks, and what it breaks into

The failures worth discussing here are the ones that produce wrong documents rather than unavailable ones. An editor that is down is obvious, recoverable, and boring; an editor that quietly gives two people different text is the failure this entire architecture is arranged to prevent, and the one an interviewer will push on.

Scenario Problem Solution Level
Connection drops mid-edit Operations already sent may or may not have committed; re-sending risks applying them twice Client keeps its pending operation keyed by client_op_id and re-sends on resume; the server deduplicates and replies with the original acknowledgement. The local text is never rolled back L4
Tab is refreshed with unacknowledged edits The pending buffer lives in memory and is gone; those keystrokes were rendered and are now lost Persist the pending operation and last revision to local storage before sending, and on load replay resume before accepting new input. Everything the user saw on screen survives a reload L4
Owning session server crashes The materialised document and the recent operation window were only in its memory Lease expires after 10 s; a new owner rebuilds from the latest snapshot plus the log tail. Acknowledged operations are safe because acknowledgement follows durability (§6); everything else is re-sent by clients L5
Operation log unavailable for writes The authority can still transform, but cannot make anything durable Stop acknowledging; clients buffer locally and keep rendering. Past a bounded window the document goes read-only with an explicit banner. It must never acknowledge from memory — an acknowledgement is a durability claim L5
100 clients reconnect at once after a crash Every one of them cold-opens the same document simultaneously The new owner materialises the document once and serves all sessions from it; snapshot fetches are per-revision immutable URLs so they collapse into one origin read. Reconnect backoff is jittered so the herd is spread over a few seconds L5/L6
Two servers both believe they own the document Both assign revision 4,173 to different operations; the documents diverge permanently and nothing downstream can tell which is correct Conditional append on lease_epoch (§7, §9): the log rejects anything below the highest epoch it has seen. The stale owner's writes fail, it drops its sessions, and its clients reconnect and resume L5/L6
Client returns after the retention window The operations it needs to transform against have been compacted; a forward transform is no longer possible resync to a fresh snapshot, then a three-way merge of the client's offline work against the last common state, surfaced as suggested edits the user reviews. Never a silent merge — the system cannot preserve intention here and should not pretend to L6
A transform-function bug diverges two clients No error is raised anywhere; two people simply have different text, possibly for weeks Carry a rolling checksum of the authority's document on every ack and remote frame. The client compares it against a shadow copy held at its last acknowledged revision — the server-agreed state, before its own in-flight and pending operations — never against what is on screen, which optimistic local apply guarantees is a different document. A mismatch force-resyncs and reports. Separately, a background job compares snapshot checksums against the broadcast value (§7), which catches a diverged log but not a transform bug. Divergence becomes a measurable rate rather than a support ticket L6
Home region becomes unreachable Both the authority and the primary copy of the operation log are in it Leases and ownership fail over to the replica region once log replication has caught up. Editing pauses for the failover window; durability is preserved and availability is what is spent. Say which one you are sacrificing — an answer that claims neither is wrong L7
Rolling out a change to transform semantics During the rollout, two servers or a server and a client can transform the same pair of operations differently, which is a divergence generator at fleet scale transform_version is negotiated at session.init; servers accept version N and N−1; a document only moves to N when every session on it supports N. Canary by document rather than by server, and gate promotion on the checksum-mismatch rate L7/L8
🎯

The probe that separates levels here: "How would you know if your OT implementation had a bug?" The L5 answer is testing — property-based tests that apply random concurrent operation pairs in both orders and assert identical results, which is genuinely the right unit-level tool. The L6 answer adds the production detector, because transform bugs live in the combinations tests did not generate. The L7 answer notices that the detector is also the rollout gate, and that without it you cannot safely change the transform functions at all — which means the observability is not a nicety, it is what makes the system maintainable.

10b

Access control, sharing links, and the right to be forgotten

The trust problems in a collaborative editor are not the usual ones. There is no public content to moderate and no money to defraud; what there is instead is a long-lived session holding privileged state, a sharing model that most breaches come through, and a history that remembers everything a user ever typed including what they deleted.

1. Permissions change while the session is open

Checking access when a document is opened is checking it at the wrong time. An editing session lasts fifteen minutes on average (§3) and can last hours, and access revocation is usually urgent precisely because someone is currently in the document.

The mechanism: every session carries the acl_version it was authorised under. The metadata service bumps that version on any permission change and pushes the change to the owning session server, which closes affected sessions with WebSocket code 4403 and discards their pending operations rather than committing them. Operations already committed stay committed — revocation is not retroactive, and claiming otherwise would require rewriting the log. The push path is the same one used for ownership handoff (§9), so this costs no new infrastructure.

Interview probe, L5/L6: "I remove someone's access while they're typing. What happens to the sentence they're halfway through?" The answer has to include that their unacknowledged operation is dropped and their already-committed text stays.

2. Sharing links are the actual attack surface

"Anyone with the link can edit" is how most real document leaks happen, and the design decision that matters is what the link contains. A link token must be an opaque, independently revocable credential stored as its own principal row in document_acl (§7) — never a value derived from doc_id, never a signed capability that cannot be withdrawn without rotating a key.

That gives the properties that matter: a link can be revoked without touching any user's access, it can carry an expiry and a domain restriction, and its usage is attributable because access via a link token is distinguishable in the audit trail from access by an identified user. Detection follows from that attribution — a link token being redeemed from dozens of unrelated networks within an hour is the signature of a link that has escaped into a mailing list or a search index.

Interview probe, L6: "A link to a confidential document was posted publicly. What can you do, and what can you not undo?" Revoke the token, enumerate the accesses from the audit trail — but the copies people already made are gone, which is the correct second half of the answer.

3. Deletion has to reach the log and every snapshot

A document is a log of everything anyone ever typed into it, including text they deleted seconds later. A delete that only removes the current state leaves all of it intact in document_ops and inside every snapshot — which is fine for a trash can and a failure for an erasure request under GDPR or similar regimes.

Hence §7's explicit state machine: active → trashed → purging → purged. Purge is an asynchronous job with a completion SLA that deletes the document's log partition, deletes every snapshot row, and removes derived copies from the search index and export cache. Two things make it tractable: partitioning by doc_id means a purge is a partition drop rather than a scan, and the tiered retention of §9 means there is far less to delete than 13 TB/day would suggest. Erasure of one user's contributions from a shared document is a much harder problem and is usually scoped out deliberately — the operations are interleaved with other people's and removing them changes the document.

Interview probe, L6/L7: "A user asks you to delete everything they typed in a shared document. Can you?" The valuable answer distinguishes deleting a document they own from surgically removing their operations from someone else's document, and explains why the second is not the same request.

4. A client that lies, and one that is merely broken

The protocol in §5b is generated by client code the server does not control. Three validations are non-negotiable, and all three are cheap:

Input Mechanism What it prevents
Identity in the payload user_id is taken from the connection token, never from the frame; a user_id field in an ops frame is ignored, not trusted Attributing edits to another user, and bypassing per-user permissions
Out-of-range positions After transforming, the server bounds-checks every position against its own materialised state and rejects the frame rather than clamping A malformed or hostile operation corrupting the document, and a clamp silently producing text nobody typed
Volume and size Operations capped at 64 KB; per-session rate limited with INCR rl:{session_id}:{epoch_second} and a 1 s TTL, rejecting above 20 ops/s with a rate_limited frame carrying retry_after_ms One runaway client saturating the single machine that owns a document — which, because the document cannot be sharded (§3), is a denial of service against everyone else editing it

Interview probe, L5: "What stops a modified client from sending an operation at position ten million?" Bounds-checking after transform, rejection rather than clamping, and the observation that the cost of getting this wrong is a corrupted document rather than an error page.

💡

Notice what ties these four together: the document is an unsplittable shard with exactly one owner, so every abuse vector here is amplified by the fact that the blast radius of a bad actor is everyone editing the same document, on the same machine, at the same moment. That is why the rate limit is per session on the write path rather than per user at the edge, and why the editor cap from §2 is as much a safety mechanism as a capacity one.

11

How to answer this question at your level

This question separates levels unusually cleanly, because the depth ladder is built into the problem: a working editor, then a justified convergence mechanism, then keeping exactly one authority alive through failure, then evolving the transform semantics without corrupting anything.

L4 A real-time editor that works ›
What good looks like
  • Persistent connections rather than polling, and a clear reason why
  • Edits applied locally first and sent asynchronously — states that the round trip is not on the render path
  • A server that assigns an order and broadcasts to the other clients
  • Recognises that applying edits in arrival order produces different documents, even without knowing the fix
  • Document state persisted somewhere durable; reconnect reloads it
What separates L5 from here
  • Names the convergence problem but has no mechanism for it
  • Treats "last write wins on the whole document" as acceptable
  • Stores the current text as the source of truth, so version history has nowhere to come from
L5 A justified convergence mechanism ›
What good looks like
  • Picks OT or CRDT and can describe the other one accurately
  • Writes a transform for at least the insert/delete pair, and knows why same-position inserts need a tie-break
  • Operation log as the source of truth, with periodic snapshots to bound replay
  • Reconnect and resume from a revision number; idempotency keys for re-sent operations
  • Presence kept off the durable path, with a reason
  • Capacity worked from concurrency rather than from raw QPS
What separates L6 from here
  • Assumes the owning server never dies, or waves at "failover" without ownership semantics
  • Has no answer for how a transform bug would be noticed
  • Treats history retention as unlimited, and so has no offline-window story
L6 One authority, surviving failure ›
What good looks like
  • Ownership by lease, not by hash, and can explain the difference in one sentence
  • Fencing epoch on the append, and why checking the lease is insufficient
  • Divergence detection via rolling checksums, with an error budget attached
  • History tiering, and the recognition that it is the same number as the offline window
  • ACL revocation reaching live sessions, not just the open endpoint
  • Observability that is specific: checksum mismatch rate, lease-renewal failure rate, p95 propagation per document, broadcast queue depth on the hottest documents
  • Knows the document is an unsplittable shard and surfaces that as a product cap
What separates L7 from here
  • Designs for the current transform functions with no plan for changing them
  • Cannot say which component dominates cost, or what it would cut first
  • Treats the convergence algorithm as a fixed input rather than a reversible decision
L7 / L8 Evolving the system without corrupting it ›
What good looks like
  • Versioned transform semantics with per-document canarying, gated on the divergence metric
  • Challenges a requirement: "does history need keystroke granularity, or would five-minute groups from day one cut the raw tier tenfold, 13 TB/day to 1.3?" — and knows the answer costs §2's offline promise, because they are the same mechanism
  • A cost model — the operation log dominates, and tiering is the lever
  • Geo strategy that admits the limit: a total order means a home region, and cross-continent co-editing has no free answer
  • Can say when they would abandon OT for a CRDT, and what the product would lose (history granularity, anchor stability, storage)
  • Frames the editor cap as a product decision with an engineering cause, not as a bug
Distinguishing markers
  • Reasons about migration paths between convergence algorithms, not just about choosing one
  • Brings in compliance and retention as design inputs rather than as afterthoughts
  • Estimates what the team and timeline for this actually look like

Classic interview probes

Question L4 answer L5/L6 answer L7/L8 answer
"Two users type at the same position at the same instant. What does each see?" Both characters end up in the document; the server decides the order Transform breaks the tie on a total order over site identifiers, applied identically on client and server; each user's own character never moves relative to what they were looking at Adds that this is exactly the TP1 condition, that a client/server disagreement here is silent and permanent, and that it is what the checksum detector exists to catch
"A user edits offline for a week and reconnects." Their changes are sent when they come back online Beyond the retention window the operations they missed no longer exist in transformable form, so it becomes a resync plus a three-way merge surfaced for review, not a silent transform Observes that the offline promise and the raw-operation retention are one number, prices extending it against §3's storage, and argues for the reviewed merge as the better product outcome rather than a limitation
"How do you know the clients haven't silently diverged?" Users would report that the document looks wrong A rolling document checksum on every acknowledgement and broadcast, compared client-side against a shadow copy at the last acknowledged revision; a mismatching client force-resyncs and reports. Knows the snapshot audit is a separate check that catches a diverged log, not a transform bug Treats the mismatch rate as an SLI with an error budget, and makes it the gate on rolling out any transform change — without it the transform functions are effectively frozen
"Why not just use a CRDT?" OT is what Google Docs uses A trusted server already exists for permissions and durability, so the total order is free; OT keeps operations small and makes the revision log double as version history, where a sequence CRDT pays per-character identifier metadata and retains tombstones Names the real dividing line — server-authoritative OT needs only TP1, decentralised OT needs TP2 — and specifies the conditions under which they would switch: local-first, peer-to-peer, or unbounded offline, accepting coarser history as the price
12

Numbers to know

Every architectural decision in this article is downstream of about twenty numbers. Not one of them needs to be precise. What earns credit is knowing which number settles which argument: the reason the server cannot sit on the render path, the reason a document cannot be sharded, the reason the offline promise and the compaction schedule are the same number written twice.

Each row below is one number and the argument it closes, in that order. “One frame at 60 Hz is 16 ms, a round trip is four frames, so the server is not on the render path” is a complete argument in one breath; the same number without the second clause is recall.

Latency: the numbers that set the propagation budget

NumberValueWhat it settles
One frame at 60 Hz 16 ms Whether the server may sit on the render path. A round trip is four frames (§2, §6)
Perceptual simultaneity in co-editing ~200 ms The propagation NFR itself: below it, two people typing reads as simultaneous (§2)
One network hop within a continent ~30 ms The floor under the budget. Two hops is 60 ms of the ~155 ms total, and untunable (§6)
Round trip Singapore ↔ Iowa ~190 ms Why documents have a home region: cross-region alone lands propagation near 285 ms (§9)
Durable replicated log append 8 ms Whether broadcast waits for durability. 5% of the budget, and no repair path without it (§2, §6)
Transform against everything since base revision ~2 ms That OT's compute is free at this scale; every expensive line is network or policy (§5, §6)
Client coalescing window ≤50 ms Where the slack is. Neither knob encodes a guarantee, so reach for these first (§6, §9)
Replaying 1,000 operations in-process ~5 ms at ~200 K ops/s That snapshot cadence is set by the write side; replay would accept ten times more (§8)

Throughput ceilings: the numbers that decide how many boxes

These answer “how much fits on one machine?” — which in this system is two separate questions, because the fleet and the busiest document are sized by different things and only one of them can be solved with money.

NumberValueWhat it settles
One session server, attached sessions 50,000 The fleet size, and that connections bind before transform cost: 63 servers, not 50 (§3)
One session server, ops transformed and broadcast 25,000 / sec The constraint that takes over if editors get busier: at 1.0 ops/s the fleet doubles (§3)
Broadcast on the busiest document 980 msgs/sec at 50 editors The number the design is built around. Quadratic: 10 editors to 50 is 27× (§3, §9)
The same at the product cap 3,960 msgs/sec at 100 editors Why the cap is 100 — a capacity number surfaced as a product rule (§2, §9)
Broadcast batching on a 50 ms frame 20 outbound frames/sec per client What batching actually buys: 2× at the cap, almost nothing at the defaults (§9)
Snapshot writes, per revision vs per 1,000 75 GB/s vs 75 MB/s Snapshot cadence. Three orders of magnitude is not a tuning range (§8)

Volume and cadence: the numbers that decide what gets kept

NumberValueWhat it settles
Average serialised operation 120 bytes Every storage figure downstream of it — id, revision, type, position, a coalesced run (§3)
Editors per open document 1.4 document-weighted
4.4 operation-weighted
Broadcast multiplies operations, and operations cluster in crowded documents — so the multiplier is the size-biased E[E²]/E[E] = 4.4. Using 1.4 puts broadcast below ingest, the wrong shape entirely (§3)
Raw operation log 13 TB/day Why history is tiered rather than retained, and why presence never touches the log (§3, §4, §9)
After revision-group compaction ~1.3 TB/day The 10× that makes version history affordable, at the granularity a user scrubs through (§9)
Offline window, and raw retention 24 hours — one number That the offline promise and raw retention are one policy: change either and both move (§2, §9)
Materialised snapshot ~200 KB, ~60 KB compressed The cold-open budget: ~200 ms against a 500 ms target, with the front end dominating (§8)
Ownership lease TTL, and renewal 10 s, renewed every 3 s What an unplanned failover costs: up to 10 s buffered locally. A planned one, milliseconds (§9)
Presence field TTL, and heartbeat 45 s, refreshed every 15 s Three heartbeats of slack — and why the expiry has to be per field, not key-level (§7, §8)
Fencing epoch on the operation row 8 B of 120 B ≈ 7% The price of correctness: 7% of 13 TB/day turns split brain into a rejected append (§7, §9)
✓

The one that separates answers is the quadratic. Most of these numbers size something, and sizing is the easy half: 3.13 M concurrent sessions over 50 K per box is 63 servers, and 63 servers is a boring answer. The sentence after it is not — the fleet is linear in users and buys nothing, because the binding constraint is a single document on a single machine, and that is a limit surfaced as a product rule rather than solved.

Precision is not what these are for. “About a thousand messages a second on one box” reaches the same conclusion as 980, and the arithmetic is worth less than the assumption it started from — 100 M DAU, three sessions each, fifteen minutes a session — because the assumption is the part an interviewer can redirect.

How the pieces connect
Every decision in this design traces back to a requirement or a capacity number.
01
Local echo under 16 ms (§2) → a keystroke cannot wait for a server round trip → the client applies its own edit immediately and keeps it in a pending buffer (§6) → which forces the client to transform incoming remote ops against its own unacknowledged ones (§5). Optimistic apply is not an optimisation here; it is what makes the transform machinery necessary in the first place.
02
Convergence guarantee (§2) → an op that peers have already applied must never be un-appended → the oplog append is durable before the broadcast (§6) → roughly 8 ms of the 200 ms propagation budget is durability, not network. The alternative, broadcasting in parallel with the append, buys back those 8 ms and pays for them with a divergence window (§6 rationale).
03
The hottest single document binds, not aggregate QPS (§3) → a document is a shard that cannot be split, because OT needs one total order → a hard cap on concurrent editors with read-only overflow, plus replica session servers that fan out to viewers (§9). The fleet is sized by connections; the product limit is sized by one machine.
04
OT requires exactly one total order (§5) → exactly one server may accept ops for a document at a time → an ownership lease with a fencing epoch checked on every oplog append (§9) → split brain degrades into rejected appends and a client resync (§10) rather than into two silently diverging documents.
05
13 TB/day of raw ops (§3) → keeping every keystroke forever is not affordable → snapshots every 1,000 revisions plus revision-group compaction after 24 h (§8, §9) → how finely a user can scrub version history is a storage-tier decision, not a UI decision. It also enforces the offline window of §2: once ops are compacted, a returning client cannot be transformed, only merged (§10).
06
An edit session outlives a permission change (§10b) → access checked only at document open is checked too early → the ACL version is stamped on the session and revocations are pushed to session servers (§10b) → and that same push path is what tells clients to reconnect during an ownership handoff (§9). One control channel, two uses.

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series