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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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
// 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
{
"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
{
"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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 ›
- 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
- 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 ›
- 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
- 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 ›
- 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
- 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 ›
- 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
- 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 |
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
| Number | Value | What 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.
| Number | Value | What 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
| Number | Value | What 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.
- Rate Limiter System Design, atomic Redis operations, distributed race conditions, and multi-tier quota enforcement
- URL Shortener System Design, hash encoding tradeoffs, database sharding strategies, and viral key mitigation
- Web Crawler System Design, Bloom filter deduplication, politeness throttling, and distributed frontier design
- Twitter/X Feed System Design, fan-out write amplification, hybrid push/pull strategy, and celebrity threshold design
- Notification Service System Design, multi-channel delivery, idempotency keys, and priority queues at scale
- Search Autocomplete System Design, Trie data structures, prefix caching, and read-heavy scale strategies
- Key-Value Store System Design — quorum consensus, LSM trees and SSTables, and anti-entropy repair
- Distributed Cache System Design — consistent hashing, eviction and TTL policy, hot keys, and cache-aside vs write-through
- Chat System (WhatsApp) System Design, WebSocket management, transient vs persistent storage, and read receipts
- Video Streaming (YouTube) System Design, ABR streaming, CDN distribution, and metadata management
- Distributed Message Queue System Design, Kafka partition tuning, exactly-once delivery, and geo-replication
- File Storage (Dropbox / Google Drive) System Design, chunking, delta sync, conflict resolution, and global deduplication
- Ride-Sharing System Design (Uber / Lyft) — geohashing, WebSocket-driven location tracking, and ETA prediction
- Payment Processing System Design — idempotency keys, exactly-once semantics, and append-only ledger models
- Top-K Leaderboard System Design — Redis sorted sets, approximate counting, and stream aggregation
- Airbnb Booking & Reservation System — inventory locks, double-booking prevention, and async elasticsearch sync
- Photo-Sharing Feed System Design — image pipelines, CDN delivery, and social graph scaling
- Proximity Search System Design (Yelp / Google Places) — geohash indexing, quadtree partitioning, and Bayesian review ranking
- Online Judge System Design — secure sandboxing, execution queues, and worker scaling
- Ticket Booking & Flash-Sale (Ticketmaster) System Design — seat-hold contention, optimistic vs pessimistic locking, and virtual waiting rooms
- Google Maps System Design — road-graph routing, contraction hierarchies vs customisable preprocessing, vector tiles, and live-traffic ETAs
- Object Storage (Amazon S3) System Design — erasure coding across zones, durability math, and a range-partitioned metadata index
- Social Post Search System Design — per-viewer visibility filtering, over-fetch math, and fan-out tail latency