The problem
You’re asked to design a collaborative editor — Google Docs: “Multiple users edit the same document simultaneously. Each user sees the others’ edits within ~100ms. Edits never conflict or get lost. The doc works offline; when you reconnect, your changes merge cleanly.”
Looks like a real-time chat with a fancier UI. It isn’t. The hidden trap is that concurrent edits to the same character position must merge to a globally-consistent result on every client, and standard data structures (a string, a list of operations) don’t have a defensible merge semantics. The question is testing whether you understand the conflict-free merging problem — and whether you can pick OT or CRDT and defend the choice with concrete tradeoffs, not buzzwords.
Important
The central architectural tension in this problem is that the choice between OT and CRDT is not a preference — it is determined by the requirements, specifically offline support. OT requires a central server to linearize operations; CRDT does not. If offline editing is in scope, OT creates a transform-window problem that grows with offline duration. Commit to CRDT for offline support before picking any other component, and name the offline requirement as the deciding factor explicitly.
Below is how I’d walk through this.
1. Clarify before you design
First 4–6 minutes.
- Document type. Plain text? Rich text (formatting, fonts, styles)? Spreadsheets? Slides? (Each has different conflict semantics. Text is the most studied; we’ll default to rich text in this walkthrough.)
- Concurrency. How many editors per document at once? Typical (3–5)? Heavy (50)? Extreme (1000)? (Most docs are 3–5 active editors; “100+” is rare and changes the architecture.)
- Latency target. How fast must remote edits appear locally? (100–300ms is the standard interactive editing target.)
- Offline support. Must it work offline and reconcile on reconnect? (Drives the choice toward CRDT.)
- Document size. Average and maximum? (Drives memory/storage decisions for the in-memory representation.)
- History and versioning. Granular undo? Named versions? Restore-to-version?
- Permissions and sharing. Doc-level (viewer/commenter/editor)? Range-level?
- Comments and suggestions. Anchored to text positions or free-floating?
Say the interviewer confirms: rich text (basic — bold, italic, headings, lists; no tables/images for scope), typical 3–5 concurrent editors with bursts to 50, 100ms remote-edit latency, yes offline support, average doc 10K characters with 100K max, granular undo and named versions, doc-level permissions only, text-anchored comments.
2. Capacity estimate
The numbers that drive the design:
- Concurrent docs. Suppose 10M active docs/day. Of these, ~10% are being edited concurrently in any 5-minute window — ~1M concurrently-active docs at peak.
- Edits per second per active doc. Typing at ~5 chars/sec per editor × 5 editors = 25 ops/sec per doc. Most pure-text edits are 1-character operations.
- Total edit throughput. 1M docs × 25 ops/sec = 25M ops/sec globally. Big number — partition by doc.
- Fan-out per edit. Each edit broadcasts to all other editors of the same doc — usually 1–4 receivers.
- Storage. Edits are persisted as an op log. ~100 bytes per op × 25M ops/sec = 2.5 GB/sec ingest. Compactable to a fraction of that after periodic snapshotting.
Out loud: “Two distinct hot paths. Per-doc edit serialization is cheap (25 ops/sec per doc), so the partitioning unit is ‘one doc on one worker.’ The aggregate scale is in the count of active docs, not throughput per doc. This makes the architecture fundamentally a fleet of single-doc workers — same shape as the auction system, different content.”
Note
The interview signal at the capacity estimate stage is recognizing that per-doc throughput is low (25 ops/sec) but the aggregate is enormous (25M ops/sec globally), and that the correct response is partitioning — not optimization. Candidates who reach for sharding or replication to handle 25M ops/sec globally are solving the wrong problem. The right answer is “one worker per active doc, and this is a fleet-size problem, not a per-worker throughput problem.”
3. API design
WS /ws/docs/{doc_id}
client → server:
{ type: "join", user_id, since_revision? }
{ type: "op", op_id, op, parent_revision }
{ type: "presence", cursor_pos, selection? }
{ type: "ack", ack_revision }
server → client:
{ type: "snapshot", revision, content }
{ type: "op", op_id, op, revision, author }
{ type: "presence", user_id, cursor, color }
{ type: "ack", op_id, revision }
POST /api/docs
body: { title, owner_id, template? }
returns: { doc_id }
GET /api/docs/{doc_id}
returns: { doc_id, title, owner, last_modified, revision }
POST /api/docs/{doc_id}/snapshots
body: { name }
returns: { snapshot_id, revision }
Two non-obvious decisions:
- WebSocket only for the live channel; HTTP for everything else. Edits and presence flow over WS; document creation, sharing, listing, and snapshots are standard REST. The WS is a hot, stateful channel; mixing in cold-path operations would burden it unnecessarily.
- Every op carries
parent_revision. This is the basis of conflict resolution — the server knows which revision the client thought it was editing against. Withoutparent_revision, we can’t detect or resolve concurrent edits.
4. Core design choice: OT vs CRDT
This is the question. The whole problem hinges on it. Three real approaches:
(a) Locking (no concurrent edits)
Acquire a doc-level lock; only one editor at a time. The other editors are read-only until the lock releases.
- Pros. Trivially correct. No merge problem.
- Cons. Catastrophic UX. Defeats the purpose of “collaborative.” Not a real answer.
(b) Operational Transformation (OT)
Each client sends operations (insert/delete/format at a position). The server transforms each incoming op against any concurrent ops it has already accepted, producing a position-adjusted op that can be applied. Each client also transforms incoming remote ops against its local pending ops to keep state consistent.
The classic OT example: User A inserts “X” at position 5; User B deletes 3 characters at position 3, concurrently. If A’s op arrives at the server after B’s, A’s “insert at 5” must become “insert at 2” (position adjusted for B’s deletion).
- Pros. Battle-tested (Google Docs has used OT in production for over a decade). Compact ops.
- Cons. Transform functions are subtle — getting them right for all op-pair combinations is famously tricky. Requires a central server to enforce a global op order.
(c) Conflict-free Replicated Data Types (CRDTs)
Use a data structure (e.g., RGA, Yjs, Automerge) where every operation commutes by construction. Apply ops in any order on any replica → same final state.
- Pros. Decentralized. Works offline natively. Merging is associative and commutative; no transform functions.
- Cons. Larger metadata per character (each insert carries a unique ID + a parent reference). Memory and bandwidth overhead vs. pure text.
Pick: CRDT (specifically, an RGA-style sequence CRDT)
The right answer for this specific problem: the offline requirement is the deciding factor. With OT, an offline client’s queued ops require the server to be the linearizer; if the server hasn’t seen anything from this client for 4 hours, the transform window grows huge. With CRDT, the client merges locally on reconnect and the result is the same regardless of ordering. Offline support is much harder with OT than with CRDT, and Google Docs offline mode is famously the exception that proves the rule.
For a richer-text editor, we’d extend the CRDT to handle formatting attributes (each character carries a set of attribute ops, which also commute).
The op shape:
InsertOp:
op_id: <client_id, counter>
position: <left_id, right_id> // CRDT positional anchors
char: 'X'
attributes: { bold: true }
DeleteOp:
op_id: <client_id, counter>
target_id: <client_id, counter> // the op that originally inserted
FormatOp:
op_id: <client_id, counter>
range: [left_id, right_id]
attributes: { italic: true }
The CRDT’s merge semantics — insert lands between two stable neighbors regardless of who else inserted there concurrently; delete is a tombstone keyed on the original insert’s id; format ranges are attribute-set unions per character — make every op order produce the same final document.
I’d say out loud: “CRDT for the document, specifically a sequence CRDT like RGA. The offline requirement is the deciding factor — OT requires central ordering, CRDT doesn’t, and offline editing is fundamentally a ‘no central server right now’ problem. The metadata overhead is real but acceptable; the CRDT compresses well for storage and the steady-state per-character overhead is ~20–30 bytes.”
Tip
The non-obvious insight in choosing CRDT over OT is what it implies about the server’s role. With OT, the server is the arbiter — ops mean nothing until the server assigns them a global sequence number. With CRDT, the server is a replication hub — it broadcasts ops, but each client applies them independently and reaches the same result. This changes the failure semantics: an OT system without a server cannot make progress; a CRDT system without a server continues editing locally and merges on reconnect. That’s the offline guarantee.
5. Data model and storage
docs (RDBMS, sharded by doc_id):
doc_id (PK), title, owner_id, created_at, last_modified,
current_revision, snapshot_path, permissions
doc_ops (append-only log, partitioned by doc_id):
op_id (PK), doc_id, author_id, op_type, op_payload,
parent_revision, applied_at
doc_snapshots (object storage):
/docs/{doc_id}/snapshots/{revision}.crdt
/docs/{doc_id}/named/{name}.crdt
active_workers (KV / coordination):
worker_assignment[doc_id] → worker_node_id
presence (Redis, ephemeral):
presence:{doc_id} → SET<{user_id, cursor, color, last_seen}>
Why these choices:
docsin RDBMS. Catalog metadata. Standard.doc_opsas an append-only log (Kafka or Postgres logical replication). The log is the source of truth for the doc’s state; the in-memory CRDT is its materialized view.doc_snapshotsin object storage. Periodic full snapshots compact thousands of ops into a starting state for late joiners; named versions are explicit user-visible snapshots.active_workersin a coordination service (e.g., etcd, ZooKeeper). The doc is owned by exactly one worker at a time; the assignment lives here.presencein Redis — entirely ephemeral.
Topic-specific reasoning: the op-log + snapshot pattern works because editing is fundamentally append-only — every keystroke is a new event, never a modification of a past event. That’s exactly the access pattern logs are built for. Storing the materialized text as the source of truth instead would force us to invent the op log later for collaboration anyway.
6. The hot path: edit → broadcast → persist
The architecture:
flowchart LR
Client[Client] --> WS[WebSocket gateway]
WS --> Router[Doc router]
Router --> Worker[Doc worker · per doc]
Worker --> CRDT[(CRDT in-memory)]
Worker --> OpLog[(Op log · partitioned)]
Worker --> FanOut[(Pub/sub per doc)]
FanOut --> WS
WS --> OtherClients[Other clients of doc]
Walking through one keystroke:
- Client types a character. Locally generates an
InsertOpwith a freshop_idand CRDT positional anchors. Applies immediately to local state — the user sees their own typing without waiting for server. - Client sends the op over WebSocket.
- WS gateway routes by
doc_idto the doc’s owning worker. - Worker validates (auth, op_id not seen before), applies the op to its in-memory CRDT, appends to the op log synchronously.
- Worker publishes the op to the doc’s pub/sub channel.
- Other clients receive the op via their own WS connections, apply locally to their CRDT.
- All clients now agree on the doc state — guaranteed by CRDT merge semantics regardless of arrival order.
Per-op latency target: <100ms from local apply on author client → apply on remote clients. The hot path is in-memory; the only disk I/O is the op-log append (~5ms typical).
The TTL/invalidation behavior worth naming:
- In-memory CRDT persists for the duration of the worker’s ownership of the doc. When the doc goes idle (no editors for N minutes), the worker takes a final snapshot and releases the doc.
- Op log retention. Last 7 days of ops are kept hot for fast late-joiner replay. Older ops are compacted into the snapshot.
- Presence evicts on WebSocket disconnect or after 30 seconds of inactivity.
7. The single-writer per doc pattern
Every active doc is owned by exactly one worker. This is the same architectural shape as the auction worker pattern (see the auction walkthrough) and the underlying reason is the same: all ops on one doc need strict ordering for predictable merge.
Even with CRDT, ordering ops within a doc is necessary for:
- Op log linearization. The log’s order is the canonical history; replay must produce a deterministic result.
- Snapshot consistency. A snapshot at revision N must capture exactly the ops up through op N.
- Late-joiner sync. A client joining at revision N gets a snapshot + ops since N, in order.
The worker assignment mechanics:
- Client connects to a WS gateway and sends
join doc_id. - WS gateway queries the assignment service: who owns this doc?
- No owner → assignment service picks a worker (e.g., least- loaded) and assigns. Worker fetches latest snapshot + ops since.
- Owner exists → gateway connects this client to the owning worker.
- Worker crashes → heartbeat timeout in the assignment service. New worker is assigned. Late-arriving op messages are buffered briefly; the new worker rebuilds state from the log.
This handles failover cleanly: bounded recovery time, no lost ops (the log is durable), and clients don’t notice beyond a brief “syncing…” UI.
8. Offline editing and reconnection
The CRDT enables offline by construction. Walking through the flow:
- Client goes offline. Local state continues to accept ops from the user; ops are queued in a local journal.
- Each local op carries a unique
op_id(client-generated, not server-allocated) and CRDT positional anchors. - Client reconnects. Sends
join doc_id, since_revision=Nwhere N is the last server revision the client saw. - Worker sends a snapshot at the latest revision, then ops since N.
- Client merges the server’s history into its local CRDT. Conflicts resolve by CRDT merge semantics (every op commutes).
- Client sends its queued local ops. Worker applies them to its in-memory CRDT and appends them to the log.
- Other clients receive these ops as normal real-time updates.
The crucial property: a 4-hour offline edit session merges cleanly without server intervention. The CRDT’s commutative merge is what makes this work. With OT, the same flow requires the server to transform every queued op against every concurrent op that happened during the offline window — possibly thousands of transform calls.
The trade-off candidates often miss: offline edits can produce weird-but-correct outcomes. Two users typing at “the same sentence” while offline produce a merge where their text is interleaved character-by-character at the position. Correct, but sometimes confusing. UI affordance — “show what other users wrote during your offline session” — is the product fix; the system’s job is just merge correctness.
9. Persistence and snapshots
The op log grows linearly with edits; snapshots compact it.
Two kinds of snapshots:
- Implicit periodic snapshots. Every N ops or every M minutes of inactivity, the worker writes a CRDT snapshot to object storage. Subsequent ops are appended to the log starting from this snapshot’s revision.
- Named user snapshots. When the user clicks “save version,”
a named snapshot is taken explicitly. Stored under
/docs/{doc_id}/named/{name}.crdt. Used for restore.
Restore semantics: restoring to a named snapshot creates a new revision that contains the snapshot’s content. We do not “undo to that point” — that would conflict with subsequent ops made by other editors. The restore is a forward op that replaces content, recorded in the log like any other op.
Extending the architecture diagram with persistence and snapshots:
flowchart LR
Client[Client] --> WS[WebSocket gateway]
WS --> Router[Doc router]
Router --> Worker[Doc worker]
Worker --> CRDT[(CRDT in-memory)]
Worker --> OpLog[(Op log)]
Worker --> FanOut[(Pub/sub)]
FanOut --> WS
Worker --> Snapshot[Snapshot service]
Snapshot --> ObjStorage[(Object storage)]
OpLog -.->|compaction| Compactor[Compaction job]
Compactor --> ObjStorage
classDef new fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
class Snapshot,ObjStorage,Compactor new
10. Presence and cursors
A separate, lower-priority channel from edits. Each client publishes its cursor position and selection range every ~100ms (throttled). The fan-out is best-effort; lost presence updates are corrected by the next one.
Why we don’t put presence on the op log: presence is ephemeral and lossy. Persisting every cursor move would 100× the log size for no value (no one wants the history of where someone’s cursor was at 14:03:21).
Presence storage: Redis hash per doc, with each user’s last known position and a TTL of ~30 seconds. WS gateways subscribe to the per-doc presence channel and forward to other connected clients.
The ergonomics: ~5 presences per active doc × 1M active docs × 10 updates/sec = 50M updates/sec. Significant but absorbed by Redis pub/sub with channel sharding for hot docs.
11. Failure modes
Name what fails, name what degrades gracefully, name what doesn’t.
- Worker crashes mid-session. Detected by heartbeat timeout (~3 sec). New worker is assigned, replays log from latest snapshot. Clients see brief “reconnecting” state, then resume. No ops lost (log is durable). Brief edit blackout, recoverable.
- Op log unavailable. Worker can’t append → returns error to client → client buffers ops locally. On log recovery, clients flush the buffer. Doc is read-only briefly; not blacked out.
- Network split between client and server. Client continues in offline mode. On reconnect, ops sync per §8.
- Two workers think they own the same doc. Split-brain. The log requires a fencing token tied to the assignment service’s generation counter. Stale-token writes are rejected by the log. This is a load-bearing correctness mechanism — without it, dual-writer scenarios produce permanently divergent state.
Warning
Unlike auction split-brain (where divergent bid histories are detectable and have a clear winner), collaborative editor split-brain produces a subtler failure: two workers each accept some ops, both appended to the log under different fencing tokens, and the resulting merged document is corrupt in a way that may not be immediately visible. The document “looks fine” to each set of editors but differs between them. Detecting this requires a cross-client document hash comparison — not just a “did I get an error?” check. Name this in failure modes; it demonstrates you understand what divergence actually looks like at the application layer.
- Client clock skew. CRDT op IDs include client-generated Lamport timestamps for ordering ties. Wall-clock skew is tolerated; logical-clock skew is not (clients must implement Lamport correctly).
- Snapshot service down. Op log keeps growing. Replays become slower for late joiners. Recoverable when snapshots resume.
- Hot doc with 1000 editors. A single worker may not handle 1000 ops/sec smoothly. We’d cap concurrent active editors per doc at 100 (UX) and queue further joiners. Beyond that, the doc is logically read-only with a “request edit access” flow.
Caution
The most common candidate mistake in the offline reconnection section is assuming that CRDT merge is always user-friendly. It’s correct, but “correct” and “intuitive” are different things. Two users who both offline-edit the same paragraph will see their text interleaved character-by-character at the merge point. The system is right; the user is confused. Name this explicitly: the product fix (show a diff of what each user wrote during the offline window) is a UI concern, but candidates who treat CRDT correctness as synonymous with good UX are missing the user-trust problem that ships with it.
- Permission revocation mid-session. Worker checks permissions periodically (~10 sec); on revoke, ejects the user’s WS connection. Their last few ops may have been accepted; we don’t retroactively revoke them (would cause CRDT divergence on other clients).
12. What I’d skip, and say I’m skipping
Time check: a few minutes left.
- Full rich text feature surface — tables, images, embeds. Each is a CRDT extension with its own merge story. The pattern is the same; the surface area is larger.
- Spell check and grammar. Client-side concern.
- Translation, voice typing, etc. Off the core path.
- Public sharing / anonymous edit links. Same WS protocol; authentication concern.
- Compatibility with Word (.docx) etc. Import/export is its own pipeline.
- Sync to Drive folder structure / search. Adjacent system.
- Real-time anti-abuse on edit content. Required in production, but I’d say “I’d run it as an async pipeline that flags but doesn’t block edits” — the latency budget for real-time editing is too tight to gate on classification.
13. Wrap-up
The architecture is one CRDT-backed worker per active doc, with an append-only op log for durability and offline-resilient client merge semantics. Choosing CRDT over OT is the deciding architectural call — driven by the offline requirement — and everything else (single-writer per doc, log-as-source-of-truth, snapshot compaction, ephemeral presence) is consequence.
That’s the answer that lands the round.
What separates SDE II from SDE III on this question
- SDE II picks WebSockets, names “operational transformation,” draws a doc-server connection, and mentions saving.
- Staff/Principal identifies the missing requirements that would change the architecture before committing to CRDT: “does offline editing need to work on mobile where storage is constrained — because the CRDT op log grows linearly and we’d need to compact locally, which is a non-trivial mobile client concern?” Proactively maps the scaling inflection point where the single-writer model breaks: “at 50+ concurrent active editors, presence fan-out alone saturates a single worker’s network; at that point, presence and edit channels need to be served by different infrastructure.” Thinks operationally: “if a document is 500K characters with 3 years of op log and the snapshot service has been down for a week, how long does a new worker take to rebuild state on assignment — and is there a runbook that monitors snapshot freshness as a leading indicator of join latency?” Raises the build-vs-buy question explicitly: Yjs and Automerge are production-ready CRDT libraries with active maintenance; building a custom RGA is a 12-month investment with correctness risks, and for most teams the right answer is to use a library and focus engineering on the product layer. Asks “when does CRDT metadata overhead become a storage problem?” — at 100K characters with rich formatting, each character’s CRDT metadata can exceed the character itself — and proposes compaction strategies before being asked.
- SDE III commits to CRDT with a defended reason (offline requirement), names RGA-style sequence CRDT specifically, designs single-writer-per-doc with leader election + fencing tokens, treats the op log as source of truth with snapshot compaction, walks through offline reconnect explicitly, and separates presence from edit channels for cost reasons.
The difference: seeing collaborative editing as a deterministic merge problem and offline editing as the test case that distinguishes OT from CRDT, not as “WebSocket plus a database.”
Further reading
- Designing Data-Intensive Applications — Kleppmann. Chapter 9 (consistency and consensus) is the theoretical foundation; he discusses CRDTs explicitly.
- Yjs and Automerge — the two production-ready open-source CRDT libraries; reading their docs is the fastest route to understanding the mechanics.
- Martin Kleppmann’s CRDT lectures — the most accessible technical deep-dive into how CRDTs actually merge.
- Daniel Spiewak — A Conflict-Free Replicated JSON Datatype — the rich-document CRDT paper relevant to extending text to formatting.
- RGA paper — the foundational replicated growable array CRDT.
- Walkthrough: Designing an Auction System — the same single-writer-per-entity pattern in a different domain.
- Walkthrough: Designing a Chat System — the WebSocket + presence patterns are the same.