Design a Collaborative Document Editor (Google Docs / Notion)
An end-to-end interview-ready walkthrough — from back-of-envelope math through deep dives on OT vs CRDTs, server-authoritative ordering, WebSocket synchronization, presence tracking, offline editing, and version history. Structured to mirror the arc of a 45-minute system design interview.
Requirements
A collaborative document editor is one of the hardest real-time systems to build correctly. The core challenge isn't rendering text — it's ensuring that when 20 people type simultaneously in the same paragraph, every user sees the same final document without any edits being lost or corrupted. This is a distributed consensus problem disguised as a text editor.
Functional Requirements
Core business logic & features
- 01.Real-Time Collaborative EditingMultiple users edit the same document simultaneously. Changes appear on all clients within 100-200ms.
- 02.Conflict-Free Concurrent EditsWhen two users type at the same position, both edits are preserved. No data loss, no corruption.
- 03.Live Cursor & PresenceSee other users' cursors, selections, and names in real-time. Know who's viewing the document.
- 04.Document VersioningFull revision history. View any past version. Restore to a previous state. See who changed what.
- 05.Comments & SuggestionsInline comments anchored to text ranges. Suggestion mode that proposes changes without applying them.
- 06.Offline EditingContinue editing without internet. Sync changes when connection is restored without conflicts.
Non-Functional
System constraints
Latency
Local edits appear instantly (0ms). Remote edits visible within 100-200ms over stable connection.
Consistency
All clients converge to the same document state. No edit is ever silently lost.
Scale
Support 50+ concurrent editors per document. Platform handles 10M+ active documents.
Durability
Zero data loss. Every keystroke is persisted. Document survives server crashes.
🎯 Clarifying questions worth asking
Each answer fundamentally changes the conflict resolution strategy:
- Rich text or plain text? (Rich text = tree structure; plain text = linear sequence. Tree conflicts are harder.)
- How many concurrent editors per document? (5 vs 50 vs 500 changes the fan-out and conflict frequency.)
- Is offline editing required? (Offline = client must buffer and rebase operations. Changes the entire sync model.)
- Block-based (Notion) or free-form (Google Docs)? (Block-based reduces conflict surface — edits within a block don't conflict with other blocks.)
- What's the maximum document size? (A 100-page doc with 50 editors has different perf characteristics than a short note.)
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| Real-time text collaboration | Spreadsheet / whiteboard collaboration | Different data models — spreadsheets are cell-based, whiteboards are spatial |
| Conflict resolution (OT/CRDT) | Conflict-free by design (locking) | Locking prevents collaboration — the whole point is concurrent editing |
| Presence & cursors | Video/audio conferencing | Separate real-time system with different latency requirements |
| Version history & restore | Git-style branching & merging | Document editors use linear history, not DAG-based version control |
| Comments & suggestions | Full workflow/approval system | Business logic layer — not a distributed systems challenge |
| Offline editing & sync | Peer-to-peer sync (no server) | P2P adds NAT traversal, discovery — separate problem entirely |
💡 Interviewer signal
The strongest opening: "This is fundamentally a distributed consensus problem. Multiple clients maintain local replicas of the document and must converge to the same state despite concurrent, potentially conflicting edits arriving in different orders. The core decision is whether to use OT (server-authoritative ordering) or CRDTs (mathematically guaranteed convergence without coordination)." This frames the entire interview.
Back-of-Envelope Estimation
A collaborative editor's load profile is fundamentally different from request-response systems. Instead of discrete HTTP requests, you have persistent WebSocket connections streaming a continuous flow of small operations. The math is about concurrent connections, operations per second per document, and the fan-out cost of broadcasting each operation to all collaborators.
Traffic: Operations Per Second
A single user typing generates roughly 3-5 operations per second (characters, with occasional batching). But operations aren't just inserts — they include deletes, formatting changes, cursor moves, and selection changes. The total operation rate per active user is higher than you'd expect.
Per-user operation rate (actively typing):
Character inserts: 3-5 ops/sec (typing speed ~60 WPM)
Deletes/backspace: 0.5-1 ops/sec
Formatting: 0.1 ops/sec (bold, italic, heading changes)
Cursor/selection: 2-3 ops/sec (mouse clicks, arrow keys)
─────────────────────────────────
Total per active user: ~8 ops/sec
Per-document (20 concurrent editors, 5 actively typing):
Active typing ops: 5 users × 8 ops/sec = 40 ops/sec
Presence updates: 20 users × 0.5/sec = 10 ops/sec (cursor position)
─────────────────────────────────
Total per document: ~50 ops/sec
Platform scale (Google Docs level):
Active documents: 10M documents with ≥1 user connected
Collaborative docs: 1M documents with ≥2 users (10%)
Peak concurrent: 50M WebSocket connections
Total operations: 1M docs × 50 ops/sec = 50M ops/sec platform-wide
Fan-Out Cost
Every operation from one user must be broadcast to all other users in the same document. This is the cost — and it's the primary scaling constraint.
Per-document fan-out (20 collaborators):
Operations generated: 50 ops/sec
Fan-out per op: 19 messages (to all other users)
Total messages: 50 × 19 = 950 messages/sec per document
Platform-wide fan-out:
1M collaborative docs × 950 msgs/sec = 950M messages/sec
But most docs have 2-3 collaborators, not 20:
Realistic average: 1M docs × 2.5 collaborators × 8 ops/sec × 1.5 fan-out
= 30M messages/sec (more realistic)
Message size:
Typical operation: ~100-200 bytes (type, position, content, metadata)
Presence update: ~50 bytes (cursor position, user ID)
Bandwidth:
30M msgs/sec × 150 bytes = 4.5 GB/sec outbound
Per WebSocket server (10K connections): ~900 KB/sec outbound (trivial)
Storage
Document storage is modest compared to the operation log. The documents themselves are small (average 50KB), but the append-only operation log grows fast — every keystroke is recorded. Without snapshot compaction, a year of active editing produces hundreds of gigabytes of operation history.
Document storage:
Average document size: 50 KB (text content, ~10 pages)
10M active documents: 500 GB (current state only)
Operation log (for version history):
Per document: 50 ops/sec × 3600 sec/hour × 8 hours/day = 1.44M ops/day
Per operation: 150 bytes
Daily log per doc: 216 MB (for an actively edited doc)
But most docs are edited for minutes, not hours:
Realistic: avg 500 ops/day per active doc × 150 bytes = 75 KB/day
10M docs × 75 KB/day = 750 GB/day of operation logs
→ 274 TB/year (without compaction)
Snapshot compaction:
Store full snapshot every 1000 operations
Discard individual ops older than snapshot
Reduces storage by ~90%: 274 TB → ~27 TB/year
Implication:
→ Operation log grows fast. Snapshot compaction is mandatory.
→ Hot documents (actively edited) keep ops in memory/Redis.
→ Cold documents (not edited in 24h) only store snapshots in blob storage.
WebSocket Connections
At Google Docs scale, the platform maintains tens of millions of persistent WebSocket connections simultaneously. Each connection consumes memory for buffers, user state, and document association. The key constraint: all users editing the same document must connect to the same server instance (or server group), which limits how freely you can distribute connections.
Platform connections:
50M concurrent WebSocket connections (Google Docs scale)
Per server capacity:
Single server: ~50K-100K WebSocket connections (with epoll/kqueue)
Memory per connection: ~10 KB (buffers, state, user metadata)
Memory per server: 100K × 10 KB = 1 GB (connection state only)
Servers needed:
50M / 100K = 500 WebSocket servers minimum
With 2× headroom: 1000 servers
Connection routing:
All users editing the same document MUST connect to the same server
(or the same server group with shared state)
→ Consistent hashing by document_id to route connections
→ If a server dies, all its documents' connections must reconnect
to the new owner (graceful handoff)
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Ops per active user: ~8 ops/sec (typing + cursor + formatting)
Ops per document (20 editors): ~50 ops/sec
Platform ops: ~30-50M ops/sec across all documents
Fan-out messages: ~30M msgs/sec platform-wide
WebSocket connections: 50M concurrent (1000 servers)
Operation log growth: ~750 GB/day (before compaction)
Snapshot compaction: Every 1000 ops → 90% storage reduction
Latency target: <200ms end-to-end for remote edits
API & Protocol Design
A collaborative editor uses two communication channels: REST APIs for document CRUD (create, load, list, delete) and a WebSocket connection for real-time operation streaming. The WebSocket protocol is the heart of the system — it defines how operations are encoded, acknowledged, and ordered.
REST APIs (Document Management)
These handle non-real-time operations: creating documents, loading initial state, managing permissions, and fetching version history. Standard request-response over HTTPS.
// Create a new document
POST /api/v1/documents
Body: { title: "Q3 Planning", workspace_id: "ws_123" }
Response: { document_id: "doc_456", title: "Q3 Planning", created_at: ISO8601 }
// Load document (initial state for editor)
GET /api/v1/documents/:documentId
Response: {
document_id: "doc_456",
title: "Q3 Planning",
content: { /* document tree / CRDT state */ },
version: 4827, // current server version (for sync)
collaborators: [ // who's currently connected
{ user_id: "u_1", name: "Alice", cursor: { block: 3, offset: 12 } }
],
permissions: { role: "editor" } // viewer | commenter | editor | owner
}
// Get version history
GET /api/v1/documents/:documentId/versions?limit=50
Response: {
versions: [
{ version: 4827, author: "Alice", timestamp: ISO8601, summary: "Edited section 3" },
{ version: 4800, author: "Bob", timestamp: ISO8601, summary: "Added table" },
]
}
// Restore to a previous version
POST /api/v1/documents/:documentId/restore
Body: { target_version: 4800 }
Response: { version: 4828, restored_from: 4800 }
WebSocket Protocol (Real-Time Operations)
The WebSocket connection carries the real-time collaboration protocol. Every message has a type, and the protocol handles operation submission, acknowledgment, remote operation delivery, and presence updates. The client maintains a local version counter that tracks which server operations it has seen.
// Connection handshake
// Client connects: wss://collab.example.com/ws?doc=doc_456&token=jwt_...
// Server responds with current state:
{
type: "init",
version: 4827, // server's current version
pending_ops: [], // ops the server has but client hasn't seen
collaborators: [...] // current presence list
}
// Client submits an operation
// Client → Server
{
type: "op",
client_id: "c_abc",
client_version: 4825, // last server version client has seen
operation: {
type: "insert",
path: [3, "children", 0, "text"], // position in document tree
offset: 12, // character offset within text node
content: "Hello", // inserted text
},
local_seq: 42 // client's local sequence number
}
// Server acknowledges (operation accepted and assigned a version)
// Server → Client (sender only)
{
type: "ack",
local_seq: 42, // which client op was acknowledged
server_version: 4828 // assigned server version
}
// Server broadcasts to other clients
// Server → Client (all others in document)
{
type: "remote_op",
server_version: 4828,
author: { user_id: "u_1", name: "Alice" },
operation: {
type: "insert",
path: [3, "children", 0, "text"],
offset: 12,
content: "Hello",
}
}
// Presence updates (cursor, selection)
// Client → Server (throttled to 2-3/sec)
{
type: "presence",
cursor: { path: [3, "children", 0, "text"], offset: 17 },
selection: null // or { anchor: {...}, focus: {...} }
}
// Server → All other clients
{
type: "presence_update",
user: { user_id: "u_1", name: "Alice", color: "#4A90D9" },
cursor: { path: [3, "children", 0, "text"], offset: 17 },
selection: null
}
Operation Types
The operation format must be expressive enough to represent any document mutation, yet simple enough to transform and compose. For a rich-text editor, operations typically fall into three categories: text mutations, structural mutations, and formatting mutations.
// Text operations (within a text node)
type TextOp =
| { type: "insert"; path: Path; offset: number; content: string }
| { type: "delete"; path: Path; offset: number; length: number }
// Structural operations (document tree)
type StructuralOp =
| { type: "insert_node"; path: Path; node: DocumentNode }
| { type: "remove_node"; path: Path }
| { type: "move_node"; from: Path; to: Path }
// Formatting operations (marks/attributes)
type FormatOp =
| { type: "add_mark"; path: Path; offset: number; length: number; mark: Mark }
| { type: "remove_mark"; path: Path; offset: number; length: number; mark: Mark }
| { type: "set_property"; path: Path; key: string; value: unknown }
// Composite operation (atomic batch)
type CompositeOp = {
type: "composite";
operations: (TextOp | StructuralOp | FormatOp)[];
// All ops in a composite are applied atomically
// Used for: paste, find-replace, undo
}
// Path: array of indices navigating the document tree
// Example: [3, "children", 0, "text"] = 4th block → children → 1st child → text content
type Path = (number | string)[];
🔑 Protocol design decisions
- client_version in every op — tells the server which operations the client has already incorporated. The server uses this to determine which ops need to be transformed against.
- Separate ack vs broadcast — the sender gets a lightweight ack (just version number). Others get the full operation. This reduces latency for the sender.
- Presence is separate from ops — cursor updates are ephemeral and lossy. Missing a cursor update is fine (next one overwrites). Missing an operation is catastrophic.
- Composite operations — paste of 500 characters is one atomic composite, not 500 individual inserts. Reduces network overhead and simplifies undo.
💡 Interviewer signal
Showing the client_version field in the operation message and explaining why it's needed — "the server must know which operations the client has already seen so it can transform the incoming op against any concurrent ops the client hasn't received yet" — demonstrates deep understanding of the OT/CRDT synchronization protocol.
Data Model
The document data model is the foundation everything else builds on. It determines how operations are expressed, how conflicts are resolved, and how efficiently the document can be rendered. There are two fundamental approaches: a flat sequence (like a string) or a tree (like HTML/JSON). Modern editors use a tree because rich text is inherently hierarchical — paragraphs contain inline elements, tables contain rows contain cells.
Document Tree Structure
The document is modeled as a tree of typed nodes. Each node has a type (paragraph, heading, list, table), optional properties (alignment, level), and children (text nodes or nested block nodes). This is the approach used by Slate.js, ProseMirror, and Notion.
// A document is a tree of nodes
// Example: "Hello **world**" in a heading
{
type: "document",
children: [
{
type: "heading",
properties: { level: 2 },
children: [
{ type: "text", content: "Hello " },
{ type: "text", content: "world", marks: ["bold"] }
]
},
{
type: "paragraph",
children: [
{ type: "text", content: "This is a paragraph with " },
{ type: "text", content: "italic text", marks: ["italic"] },
{ type: "text", content: "." }
]
},
{
type: "table",
children: [
{
type: "table_row",
children: [
{ type: "table_cell", children: [{ type: "text", content: "A1" }] },
{ type: "table_cell", children: [{ type: "text", content: "B1" }] }
]
}
]
}
]
}
Persistence Schema (Postgres)
The relational schema stores document metadata, access control, and version history. The actual document content is stored as a JSON blob (current snapshot) plus an operation log for reconstruction.
-- Documents: metadata and access control
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL DEFAULT 'Untitled',
workspace_id UUID REFERENCES workspaces(id),
owner_id UUID REFERENCES users(id),
current_version INT NOT NULL DEFAULT 0,
content_snapshot JSONB, -- latest full document tree (for fast load)
snapshot_version INT NOT NULL DEFAULT 0, -- version of the snapshot
is_archived BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Operation log: every edit ever made (append-only)
CREATE TABLE document_operations (
id BIGSERIAL PRIMARY KEY,
document_id UUID REFERENCES documents(id),
version INT NOT NULL, -- server-assigned sequential version
author_id UUID REFERENCES users(id),
operation JSONB NOT NULL, -- the operation payload
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(document_id, version)
);
CREATE INDEX idx_doc_ops_version ON document_operations(document_id, version);
-- Snapshots: periodic full-state captures (for fast loading + log compaction)
CREATE TABLE document_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id),
version INT NOT NULL,
content JSONB NOT NULL, -- full document tree at this version
created_at TIMESTAMPTZ DEFAULT NOW(),
delete_info JSONB
);
CREATE INDEX idx_snapshots_doc_version ON document_snapshots(document_id, version);
-- Comments: anchored to document positions
CREATE TABLE document_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id),
author_id UUID REFERENCES users(id),
anchor_path JSONB NOT NULL, -- path + offset range in document tree
anchor_version INT NOT NULL, -- version when comment was created
content TEXT NOT NULL,
is_resolved BOOLEAN DEFAULT false,
parent_id UUID REFERENCES document_comments(id), -- for threads
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB
);
-- Document permissions
CREATE TABLE document_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES documents(id),
user_id UUID REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'viewer', -- viewer | commenter | editor | owner
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
delete_info JSONB,
UNIQUE(document_id, user_id)
);
In-Memory State (During Active Editing)
When a document is actively being edited, its state lives in memory on the collaboration server. This includes the current document tree, the recent operation buffer (for transformation), and the presence state of all connected users.
// Held in memory on the collaboration server while document is active
interface DocumentSession {
documentId: string;
// Current document state
currentVersion: number;
documentTree: DocumentNode; // full tree in memory for fast access
// Operation buffer (recent ops for transformation)
recentOps: Operation[]; // last N ops (for transforming incoming ops)
lastSnapshotVersion: number; // version of last persisted snapshot
// Connected clients
clients: Map<string, ClientState>; // clientId → state
// Persistence queue
pendingPersist: Operation[]; // ops not yet written to DB
persistTimer: NodeJS.Timeout; // flush every 500ms or 50 ops
}
interface ClientState {
userId: string;
clientId: string;
websocket: WebSocket;
lastAckedVersion: number; // last version client confirmed receiving
cursor: CursorPosition | null;
selection: SelectionRange | null;
color: string; // assigned collaboration color
}
// Memory per active document:
// Document tree: ~50 KB (average)
// Recent ops buffer (1000 ops): ~150 KB
// Client state (20 clients): ~10 KB
// Total: ~210 KB per active document
// 100K active documents: ~21 GB (fits in a single large server)
| Storage Layer | What It Holds | Access Pattern | Durability |
|---|---|---|---|
| In-memory (collab server) | Active document tree + recent ops + presence | Every operation reads/writes here | Lost on crash — rebuilt from DB |
| Postgres (operation log) | Every operation ever applied | Append-only writes, range reads for history | Fully durable, replicated |
| Postgres (snapshots) | Full document state every 1000 ops | Written periodically, read on document load | Fully durable |
| Blob storage (S3) | Archived snapshots for cold documents | Read on document re-open after long inactivity | 11 nines durability |
📦 Snapshot + Log architecture
This is the same pattern as database : snapshots are checkpoints, operations are the log. To load a document: fetch the latest snapshot, then replay all operations after that snapshot's version. To compact: create a new snapshot and delete old operations.
💡 Interviewer signal
Explaining the snapshot + operation log pattern and drawing the parallel to database WAL shows you understand the fundamental storage trade-off: append-only logs are fast to write but slow to read (must replay). Snapshots are expensive to create but fast to load. The combination gives you both.
High-Level Architecture
The architecture centers on a stateful collaboration server that holds the document in memory and processes operations in real-time. Unlike stateless REST services that can be load-balanced freely, the collaboration server must maintain per-document state — all clients editing the same document connect to the same server instance. This is the fundamental architectural constraint.
Client Editor
Local document replica, optimistic apply, op queue
WebSocket Gateway
Connection routing, auth, heartbeat
Collaboration Server
OT/CRDT engine, operation ordering, fan-out
Persistence Layer
Operation log, snapshots, async flush
Presence Service
Cursor positions, active users, ephemeral state
The pipeline shows the flow of a single operation: the client applies it locally (instant), sends it over WebSocket to the collaboration server, which transforms it against concurrent ops, assigns a version, persists it, and broadcasts to all other clients. Total round-trip: 50-200ms depending on network.
Client Editor
Maintains a local replica of the document. Applies user edits instantly (optimistic). Queues operations for server submission. Transforms incoming remote ops against pending local ops.
WebSocket Gateway
Terminates WebSocket connections. Routes clients to the correct collaboration server based on document_id using consistent hashing. Handles auth, reconnection, and heartbeats.
Collaboration Server (Stateful)
The brain. Holds document tree in memory. Receives ops, transforms them, assigns sequential versions, broadcasts to all clients. One instance per document (or document group).
Persistence Service
Async worker that flushes operations to Postgres and creates periodic snapshots. Decoupled from the real-time path — a 500ms persistence delay is acceptable.
Presence Service
Lightweight pub/sub for cursor positions and user activity. Ephemeral — no persistence needed. Can use Redis Pub/Sub or in-memory on the collaboration server.
Document Service (REST)
Handles non-real-time operations: create, delete, list, permissions, version history. Stateless, horizontally scalable. Reads from Postgres.
The Stateful Server Challenge
The collaboration server is intentionally stateful — it holds the document tree and recent operations in memory for fast transformation. This creates challenges that stateless architectures don't have: server affinity, failover complexity, and memory management. But it's necessary because the algorithm needs access to recent operations to transform incoming edits — reading from a database on every operation would add unacceptable latency.
To ensure all clients for the same document land on the same server, we use on the document ID. This gives deterministic routing — any gateway node can compute which collaboration server owns a given document without a lookup. When a server fails, only its slice of documents migrates to neighbors on the hash ring.
// All clients for document X must connect to the same collaboration server
// Use consistent hashing to map document_id → server instance
class DocumentRouter {
private ring: ConsistentHashRing;
getServerForDocument(documentId: string): ServerInstance {
// Hash the document ID to find the owning server
return this.ring.getNode(documentId);
}
// When a server dies, its documents are redistributed to neighbors
handleServerFailure(failedServer: ServerInstance) {
const affectedDocs = this.ring.getDocumentsOnNode(failedServer);
this.ring.removeNode(failedServer);
// Each affected document's clients will reconnect
// New owner server loads document from latest snapshot + op log
for (const docId of affectedDocs) {
const newOwner = this.ring.getNode(docId);
newOwner.loadDocument(docId); // Load from persistence layer
}
}
}
// Client connection flow:
// 1. Client calls REST API: GET /api/v1/documents/:id/connect
// 2. API returns WebSocket URL: wss://collab-server-7.example.com/ws?doc=doc_456
// 3. Client connects to the specific server that owns this document
// 4. If server is unavailable, client retries with exponential backoff
// Gateway re-routes to new owner after failover
Operation Flow (End-to-End)
Let's trace a single keystroke from the moment a user presses a key to the moment every other collaborator sees it. This end-to-end flow shows why the system feels instant locally (0ms) while remote edits appear within 50-150ms — the network round-trip is the dominant cost, not the server processing.
User types "H" at position 12 in paragraph 3:
Client side (0ms):
1. Apply locally → user sees "H" instantly
2. Create operation: { type: "insert", path: [3, "text"], offset: 12, content: "H" }
3. Attach client_version: 4825 (last server version seen)
4. Add to pending queue, send over WebSocket
Network (20-50ms):
5. WebSocket frame arrives at collaboration server
Server side (0.1-1ms):
6. Receive op with client_version=4825
7. Server is at version 4828 → 3 ops happened since client last synced
8. Transform incoming op against ops 4826, 4827, 4828
(adjust position if earlier inserts shifted text)
9. Apply transformed op to server document tree
10. Assign version 4829
11. Append to operation log (in-memory buffer, async persist)
12. Send ACK to sender: { type: "ack", server_version: 4829 }
13. Broadcast to all other clients: { type: "remote_op", version: 4829, op: ... }
Other clients (20-50ms network):
14. Receive remote_op
15. Transform against any pending local ops (not yet ACKed)
16. Apply to local document tree
17. Re-render affected paragraph
Total end-to-end: 50-150ms (dominated by network RTT)
🏗️ Why stateful is necessary
- Transformation speed — transforming an op against 3 concurrent ops takes microseconds in memory. Reading those 3 ops from Postgres would take 5-10ms — unacceptable at 50 ops/sec per document.
- Fan-out efficiency — broadcasting to 20 clients from the same process is a memory copy. Cross-server fan-out adds network hops and serialization.
- Consistency — a single server assigns sequential versions. No distributed coordination needed for ordering.
💡 Interviewer signal
Acknowledging that the collaboration server is stateful — and explaining why that's a deliberate choice, not a mistake — shows architectural maturity. "Stateless is the default for web services, but real-time collaboration requires in-memory state for sub-millisecond operation transformation. The trade-off is failover complexity, which we handle with consistent hashing and snapshot-based recovery."
Conflict Resolution Engine
This is the core algorithm — the single decision that defines the system. When two users type at the same position simultaneously, how do you ensure both edits are preserved and all clients converge to the same final state? There are two serious approaches: and . Walking through both, explaining the trade-offs, and choosing one with clear reasoning is the strongest signal in this problem.
The Conflict Scenario
Before diving into solutions, let's establish the exact problem. Two users are editing the same document. The document currently contains "ABCD". Alice inserts "X" at position 1. Bob deletes character at position 2. Both edits happen simultaneously — neither has seen the other's change.
Initial document: "ABCD"
Alice's operation: insert("X", position=1) → "AXBCD"
Bob's operation: delete(position=2) → "ABD"
Without conflict resolution:
If we apply Alice then Bob: "AXBCD" → delete pos 2 → "AXCD" (Bob deleted 'B')
If we apply Bob then Alice: "ABD" → insert 'X' at pos 1 → "AXBD"
Different results! "AXCD" ≠ "AXBD"
The correct result should be: "AXBD"
(Alice's X is inserted, Bob's deletion of C is preserved)
Why? Bob intended to delete the character 'C' (the 3rd character).
After Alice's insert, 'C' moved from position 2 to position 3.
So Bob's delete should target position 3, not position 2.
Approach Comparison: Bad → Good → Optimal
Bad: Last-write-wins (no conflict resolution)
The simplest approach: whoever's edit arrives at the server last overwrites the other. This is what happens with naive auto-save in most apps. It silently loses edits — completely unacceptable for collaborative editing.
// Server receives Alice's edit, applies it
document = applyOp(document, aliceOp); // "AXBCD"
// Server receives Bob's edit (based on original "ABCD"), applies it
document = applyOp(document, bobOp); // Deletes position 2 → "AXCD"
// Alice's intent: insert X after A ✅ (preserved)
// Bob's intent: delete C ❌ (deleted B instead!)
// Result: Bob's edit corrupted the document
// Even worse with full-document replacement:
// Alice saves "AXBCD", Bob saves "ABD"
// Last save wins → one user's entire edit session is lost
Good: Operational Transformation (OT)
OT transforms concurrent operations so they account for each other's effects. When Bob's delete arrives at the server, the server knows Alice already inserted a character before Bob's target position — so it shifts Bob's delete position by +1. Both edits are preserved correctly.
// OT Transform function: adjusts op2 given that op1 was applied first
function transform(op1: Operation, op2: Operation): Operation {
// Case: insert vs delete
if (op1.type === 'insert' && op2.type === 'delete') {
if (op1.position <= op2.position) {
// op1 inserted before op2's target → shift op2 right
return { ...op2, position: op2.position + op1.content.length };
}
// op1 inserted after op2's target → no adjustment needed
return op2;
}
// Case: insert vs insert (at same position)
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position < op2.position) {
return { ...op2, position: op2.position + op1.content.length };
}
if (op1.position === op2.position) {
// Tie-breaking: use author ID ordering (deterministic)
if (op1.authorId < op2.authorId) {
return { ...op2, position: op2.position + op1.content.length };
}
return op2; // op2 goes first
}
return op2;
}
// Case: delete vs delete (same position)
if (op1.type === 'delete' && op2.type === 'delete') {
if (op1.position < op2.position) {
return { ...op2, position: op2.position - 1 };
}
if (op1.position === op2.position) {
return { type: 'noop' }; // Both deleted same char — one becomes no-op
}
return op2;
}
// ... more cases for formatting, structural ops
return op2;
}
// Server flow:
// 1. Receive Bob's op (client_version=4825, server is at 4828)
// 2. Get ops 4826, 4827, 4828 (ops Bob hasn't seen)
// 3. Transform Bob's op against each: transform(4826, bobOp) → transform(4827, ...) → ...
// 4. Apply transformed op to document
// 5. Assign version 4829, broadcast
// Limitation: transform function complexity grows O(n²) with operation types
// For rich text (insert, delete, format, split, merge, move): dozens of cases
// Google Docs team spent years getting this right
Optimal: CRDT — convergence by construction (for new systems)
CRDTs take a fundamentally different approach: instead of transforming operations after the fact, they design the data structure so that operations commute naturally. Each character gets a unique, globally-ordered ID. Inserts and deletes reference these IDs, not positions — so they're immune to reordering.
// Each character has a unique ID: (clientId, sequenceNumber)
// IDs are globally ordered using a fractional index or tree structure
// Document "ABCD" represented as:
// A: { id: (alice, 1), content: 'A', deleted: false }
// B: { id: (alice, 2), content: 'B', deleted: false }
// C: { id: (alice, 3), content: 'C', deleted: false }
// D: { id: (alice, 4), content: 'D', deleted: false }
// Alice inserts 'X' between A and B:
// X: { id: (alice, 5), content: 'X', parent: (alice, 1), deleted: false }
// Position is defined by parent reference, not index!
// Bob deletes 'C':
// C: { id: (alice, 3), content: 'C', deleted: true } // tombstone
// Deletion references the character's unique ID, not its position!
// Why this works:
// - Alice's insert references (alice, 1) as parent → always goes after A
// - Bob's delete references (alice, 3) → always deletes C
// - Order of application doesn't matter — same result either way
// - No transformation needed! Operations are commutative by design.
// The trade-off: metadata overhead
// Each character carries: clientId (8 bytes) + seq (4 bytes) + parent ref (12 bytes)
// = ~24 bytes of metadata per character
// A 50KB document (50,000 chars) → 1.2 MB of CRDT state
// Plus tombstones for deleted characters (never truly removed)
// Popular CRDT libraries:
// - Yjs (JavaScript) — used by many editors
// - Automerge (Rust/JS) — academic rigor
// - Diamond Types (Rust) — performance-focused
Head-to-Head Comparison
The choice between OT and CRDT is the defining architectural decision. It affects server architecture, memory usage, offline support, and implementation complexity.
| Dimension | OT | CRDT |
|---|---|---|
| Server requirement | Central server required (assigns order) | No central server needed (peer-to-peer possible) |
| Offline support | Hard — must buffer and rebase against server state | Natural — merge on reconnect, guaranteed convergence |
| Memory overhead | Low — only stores current document + recent ops | High — stores unique ID per character + tombstones |
| Implementation complexity | High — O(n²) transform cases for rich text | Medium — complex data structure, but no transform logic |
| Latency | Slightly higher (server must transform before broadcast) | Lower (no transformation step, direct apply) |
| Proven at scale | Google Docs (15+ years in production) | Figma, some Notion features (newer, growing adoption) |
| Undo/redo | Complex — must inverse-transform through history | Simpler — each op has a natural inverse |
| Garbage collection | Simple — old ops can be discarded after snapshot | Complex — tombstones accumulate, need periodic GC |
Which to Choose (Interview Answer)
For a system design interview, the strongest answer is:
Choose OT if:
✓ You need a proven, battle-tested approach (Google Docs model)
✓ Memory efficiency is critical (millions of documents)
✓ You already have a central server (which you do for persistence)
✓ Offline editing is not a primary requirement
Choose CRDT if:
✓ Offline-first is a core requirement (mobile apps, unreliable networks)
✓ You want peer-to-peer collaboration (no server dependency)
✓ You're building on modern libraries (Yjs, Automerge)
✓ You can tolerate higher memory usage per document
For this design (Google Docs-style):
→ OT with server-authoritative ordering
→ Server is already required for persistence, permissions, presence
→ Memory efficiency matters at 10M documents
→ Offline is supported via operation buffering + rebase on reconnect
🔄 The convergence guarantee
Both OT and CRDT guarantee eventual consistency: if all operations are eventually delivered to all clients (even out of order, even with delays), all clients will converge to the same document state. The difference is HOW they achieve it — OT through transformation, CRDT through commutativity.
💡 Interviewer signal
Presenting both approaches, explaining the trade-offs with specific dimensions (memory, offline, complexity), and making a reasoned choice — "I'd choose OT because we already need a central server for persistence and permissions, and memory efficiency matters at our scale" — is the strongest possible answer. Bonus: mention that Google Docs uses OT and Figma uses CRDTs, showing you know real-world precedent.
Operation Ordering & Server Authority
In an OT-based system, the server is the single source of truth for operation ordering. It assigns a sequential version number to each operation, and all clients must apply operations in this server-determined order. This is what makes convergence possible — without a canonical order, clients could diverge permanently.
Server-Side Processing: Bad → Good → Optimal
Bad: Accept operations in arrival order without transformation
Apply each operation as it arrives, in network arrival order. Since network latency varies, operations arrive in different orders on different clients — and without transformation, the documents diverge.
// Server just applies ops in arrival order and broadcasts
async function handleOperation(clientOp: Operation) {
// Apply directly to server document
this.document = applyOp(this.document, clientOp);
this.version++;
// Broadcast to all clients
this.broadcast({ type: 'remote_op', op: clientOp, version: this.version });
}
// Problem: Client A sent op based on version 5, but server is now at version 8.
// The op's position references are WRONG for the current document state.
// Example: "insert at position 3" — but 2 characters were inserted before position 3
// since the client last synced. The insert lands in the wrong place.
// Different clients see different documents. Permanent divergence.
Good: Transform against all concurrent operations
When an operation arrives, check its client_version against the server's current version. If there's a gap, transform the incoming operation against all operations in that gap. This ensures the operation is correct for the current document state.
async function handleOperation(clientId: string, clientOp: ClientOperation) {
const { operation, client_version } = clientOp;
// How many ops has this client missed?
const gap = this.version - client_version;
if (gap === 0) {
// Client is up-to-date — apply directly
this.applyAndBroadcast(operation, clientId);
return;
}
// Client is behind — transform against missed operations
const missedOps = this.operationLog.slice(-gap); // last N ops
let transformed = operation;
for (const missedOp of missedOps) {
transformed = transform(missedOp, transformed);
}
// Apply the transformed operation
this.applyAndBroadcast(transformed, clientId);
}
// Problem: operationLog grows unbounded
// After 1M operations, transforming a new op requires iterating 1M entries
// (if client was disconnected for a long time)
// Memory: 1M ops × 150 bytes = 150 MB per document just for the log
// Transform time: O(gap) — could be seconds for large gaps
Optimal: Bounded buffer + snapshot fallback for stale clients
Keep only the last N operations in memory (e.g., 1000). If a client's gap is within N, transform normally. If the gap exceeds N (client was offline too long), force the client to reload from the latest snapshot — don't try to transform through thousands of operations.
const MAX_BUFFER_SIZE = 1000; // Keep last 1000 ops in memory
async function handleOperation(clientId: string, clientOp: ClientOperation) {
const { operation, client_version, local_seq } = clientOp;
const gap = this.version - client_version;
if (gap > MAX_BUFFER_SIZE) {
// Client is too far behind — force reload
this.sendToClient(clientId, {
type: 'reset',
reason: 'too_far_behind',
snapshot: this.documentTree,
version: this.version
});
return;
}
// Transform against missed operations (bounded by MAX_BUFFER_SIZE)
let transformed = operation;
const missedOps = this.recentOps.slice(this.recentOps.length - gap);
for (const missedOp of missedOps) {
transformed = transform(missedOp.operation, transformed);
}
// Apply to server document
this.documentTree = applyOp(this.documentTree, transformed);
this.version++;
// Add to buffer (evict oldest if full)
this.recentOps.push({ operation: transformed, version: this.version, authorId: clientId });
if (this.recentOps.length > MAX_BUFFER_SIZE) {
this.recentOps.shift();
}
// ACK the sender
this.sendToClient(clientId, { type: 'ack', local_seq, server_version: this.version });
// Broadcast to all other clients
this.broadcastExcept(clientId, {
type: 'remote_op',
server_version: this.version,
operation: transformed,
author: this.getClientUser(clientId)
});
}
// Memory: 1000 ops × 150 bytes = 150 KB per document (bounded)
// Transform time: O(gap) where gap ≤ 1000 — always fast
// Stale clients: forced to reload (rare, only after long disconnection)
| Approach | Correctness | Memory | Stale Client Handling |
|---|---|---|---|
| No transformation | ❌ Documents diverge | O(1) | N/A (broken) |
| Unbounded buffer | ✅ Always correct | O(total ops) — unbounded | Transform through entire history (slow) |
| Bounded buffer + snapshot | ✅ Correct within buffer, reset beyond | O(N) — bounded at 1000 ops | Force reload from snapshot (fast, clean) |
Client-Side: Optimistic Apply + Rebase
The client doesn't wait for server acknowledgment before showing the user's edit. It applies locally immediately (), then reconciles when the server responds. This is what makes the editor feel instant despite network latency.
class CollaborationClient {
private document: DocumentTree;
private serverVersion: number;
private pendingOps: Operation[] = []; // sent but not ACKed
private localBuffer: Operation[] = []; // not yet sent
// User types a character
handleLocalEdit(op: Operation) {
// 1. Apply immediately (user sees it instantly)
this.document = applyOp(this.document, op);
// 2. Buffer the operation
this.localBuffer.push(op);
// 3. If nothing is pending (waiting for ACK), send immediately
if (this.pendingOps.length === 0) {
this.flushBuffer();
}
// Otherwise, wait for ACK before sending next batch
}
// Server acknowledges our operation
handleAck(ack: { local_seq: number; server_version: number }) {
// Remove from pending
this.pendingOps.shift();
this.serverVersion = ack.server_version;
// Send next buffered ops if any
if (this.localBuffer.length > 0) {
this.flushBuffer();
}
}
// Receive a remote operation from another user
handleRemoteOp(remoteOp: { operation: Operation; server_version: number }) {
this.serverVersion = remoteOp.server_version;
// Transform remote op against ALL our pending + buffered ops
// (because our local state includes ops the server hasn't seen yet)
let transformed = remoteOp.operation;
for (const pendingOp of this.pendingOps) {
const [transformedRemote, transformedLocal] = transformPair(transformed, pendingOp);
transformed = transformedRemote;
// Also update our pending op (it now accounts for the remote change)
}
for (const bufferedOp of this.localBuffer) {
const [transformedRemote, transformedLocal] = transformPair(transformed, bufferedOp);
transformed = transformedRemote;
}
// Apply the transformed remote op to our local document
this.document = applyOp(this.document, transformed);
// Re-render the affected region
}
}
🔄 The OT invariant
The fundamental invariant that makes OT work: if operations A and B are concurrent, then apply(apply(doc, A), transform(A, B)) must equal apply(apply(doc, B), transform(B, A)). This is called the transformation property (TP1). Every transform function must satisfy this — if it doesn't, clients will diverge.
💡 Interviewer signal
Drawing the client state diagram — "pending ops waiting for ACK, buffered ops not yet sent, and how remote ops are transformed against both" — shows you understand the full client-server protocol, not just the server side. This is the level of detail that separates a memorized answer from genuine understanding.
Real-Time Sync (WebSocket Layer)
The WebSocket layer is the transport that carries operations between clients and the collaboration server. It must handle connection lifecycle (connect, disconnect, reconnect), message ordering guarantees, batching for efficiency, and graceful degradation when the network is unreliable. Getting this layer wrong means lost operations, duplicate deliveries, or permanent client divergence.
Connection Lifecycle
A WebSocket connection goes through several states during a collaboration session. The client must handle each transition gracefully — especially reconnection, which is the most common failure mode in real-world usage (laptop sleep, WiFi switch, mobile network change).
Connection States:
CONNECTING → SYNCING → ACTIVE → DISCONNECTED → RECONNECTING → SYNCING → ...
CONNECTING:
Client opens WebSocket to collaboration server
Sends auth token + document_id + last_known_version
SYNCING:
Server sends all operations since client's last_known_version
Client applies them to catch up to current state
If gap > 1000 ops: server sends full snapshot instead
ACTIVE:
Bidirectional operation streaming
Client sends local ops, receives remote ops + ACKs
Heartbeat every 30 seconds (detect dead connections)
DISCONNECTED:
Network failure detected (heartbeat timeout or WebSocket close)
Client buffers all local operations in memory
Shows "Reconnecting..." indicator to user
User can continue editing (offline mode)
RECONNECTING:
Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
On reconnect: send last_known_version + all buffered ops
Server transforms buffered ops against missed server ops
Resume ACTIVE state
Message Delivery Guarantees: Bad → Good → Optimal
Bad: Fire-and-forget (no acknowledgment)
Send operations over WebSocket without tracking delivery. If a message is lost (network blip, server restart), the operation is gone forever. The client and server diverge silently.
// Client sends op and forgets about it
websocket.send(JSON.stringify({ type: 'op', operation: op }));
// If this message is lost in transit:
// - Client thinks it was applied (it's in local state)
// - Server never received it (not in server state)
// - Documents permanently diverge
// - No mechanism to detect or recover from this
Good: ACK-based with retry
Every operation gets an acknowledgment from the server. If no ACK arrives within a timeout, the client retries. This guarantees at-least-once delivery but can cause duplicates if the ACK was lost (not the operation).
class ReliableTransport {
private pendingAcks = new Map<number, { op: Operation; timer: NodeJS.Timeout }>();
private localSeq = 0;
send(op: Operation) {
const seq = ++this.localSeq;
const message = { type: 'op', local_seq: seq, operation: op };
this.websocket.send(JSON.stringify(message));
// Set retry timer
const timer = setTimeout(() => this.retry(seq), 5000);
this.pendingAcks.set(seq, { op, timer });
}
handleAck(ack: { local_seq: number }) {
const pending = this.pendingAcks.get(ack.local_seq);
if (pending) {
clearTimeout(pending.timer);
this.pendingAcks.delete(ack.local_seq);
}
}
private retry(seq: number) {
const pending = this.pendingAcks.get(seq);
if (!pending) return;
// Resend the operation
this.websocket.send(JSON.stringify({ type: 'op', local_seq: seq, operation: pending.op }));
pending.timer = setTimeout(() => this.retry(seq), 10000); // longer timeout on retry
}
}
// Problem: if server received the op but ACK was lost,
// server gets the same op twice. Must deduplicate server-side.
Optimal: ACK + sequence numbers + server-side dedup
Combine ACK-based retry with sequence numbers for deduplication. The server tracks the last processed sequence number per client. Retried operations with already-processed sequence numbers are silently dropped (idempotent). This gives exactly-once semantics.
// Server-side deduplication
class DocumentSession {
// Track last processed seq per client
private clientSeqs = new Map<string, number>();
handleClientMessage(clientId: string, message: ClientMessage) {
if (message.type !== 'op') return;
const lastSeq = this.clientSeqs.get(clientId) ?? 0;
if (message.local_seq <= lastSeq) {
// Already processed — this is a retry. Send ACK again but don't apply.
this.sendToClient(clientId, {
type: 'ack',
local_seq: message.local_seq,
server_version: this.version // current version (op was already applied)
});
return;
}
if (message.local_seq !== lastSeq + 1) {
// Gap in sequence — client skipped a number. Request resync.
this.sendToClient(clientId, { type: 'resync_required' });
return;
}
// New operation — process normally
this.clientSeqs.set(clientId, message.local_seq);
this.processOperation(clientId, message);
}
}
// Result: exactly-once semantics
// - Lost op → client retries → server processes (first time)
// - Lost ACK → client retries → server deduplicates (already processed)
// - Network reorder → sequence gap detected → resync
// - No data loss, no duplicates, no divergence
Operation Batching
Sending every keystroke as a separate WebSocket message creates excessive overhead (frame headers, syscalls). Batching groups multiple operations into a single message, reducing network overhead while maintaining low perceived latency.
class OperationBatcher {
private buffer: Operation[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private readonly maxBatchSize = 20;
private readonly maxDelay = 50; // ms — max time before flush
add(op: Operation) {
this.buffer.push(op);
// Flush immediately if batch is full
if (this.buffer.length >= this.maxBatchSize) {
this.flush();
return;
}
// Otherwise, set a timer to flush after maxDelay
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), this.maxDelay);
}
}
private flush() {
if (this.buffer.length === 0) return;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
// Compose multiple ops into a single composite operation
const composite: CompositeOperation = {
type: 'composite',
operations: [...this.buffer]
};
this.transport.send(composite);
this.buffer = [];
}
}
// Behavior:
// Fast typing (10 chars/sec): batches of 1-2 ops every 50ms
// Paste (500 chars): single composite op, sent immediately
// Idle then type: first char sent after 50ms delay (imperceptible)
// Net effect: 60-80% reduction in WebSocket messages with <50ms added latency
| Strategy | Messages/sec (typing) | Added Latency | Bandwidth |
|---|---|---|---|
| No batching (every keystroke) | 8-10 msgs/sec | 0ms | High (frame overhead per char) |
| Fixed 100ms batch | ~10 msgs/sec | 0-100ms | Low (but 100ms feels sluggish) |
| Adaptive (50ms max, size cap) | ~4-6 msgs/sec | 0-50ms (imperceptible) | Low (best of both worlds) |
🔌 Handling WebSocket disconnection gracefully
- Detect quickly — heartbeat every 30s. If 2 heartbeats missed (60s), consider disconnected.
- Buffer locally — all operations during disconnection are queued in memory (and IndexedDB for crash safety).
- Reconnect with state — send last_known_version on reconnect. Server sends missed ops or full snapshot.
- Rebase buffered ops — transform local buffered ops against the missed server ops before re-submitting.
💡 Interviewer signal
Mentioning exactly-once delivery via sequence numbers + server-side dedup shows you understand that WebSocket is TCP (ordered, reliable per connection) but connections break and retry. The combination of client-side retry + server-side dedup is the standard pattern for reliable real-time messaging.
Presence & Cursor Tracking
Presence — seeing other users' cursors, selections, and names in real-time — is what makes collaborative editing feel alive. Unlike document operations (which must be perfectly consistent), presence is ephemeral and lossy. A missed cursor update is harmless — the next one overwrites it. This difference in requirements allows a much simpler, more efficient implementation.
Presence Data Model
Each connected user has a presence state that includes their cursor position, active selection (if any), display name, and assigned color. This state is updated 2-3 times per second as the user moves their cursor or changes their selection.
interface UserPresence {
userId: string;
name: string;
avatarUrl: string;
color: string; // assigned collaboration color (#4A90D9, #E74C3C, etc.)
// Cursor position (where the caret is)
cursor: {
path: Path; // path in document tree (e.g., [3, "children", 0, "text"])
offset: number; // character offset within the text node
} | null; // null = user has no cursor in document (e.g., in comments panel)
// Selection range (highlighted text)
selection: {
anchor: { path: Path; offset: number }; // where selection started
focus: { path: Path; offset: number }; // where selection ended
} | null;
// Activity state
lastActiveAt: number; // timestamp of last edit/cursor move
status: 'active' | 'idle' | 'away'; // idle after 60s, away after 5min
}
// Color assignment: deterministic based on user position in collaborator list
const COLLABORATION_COLORS = [
'#4A90D9', '#E74C3C', '#27AE60', '#F39C12',
'#9B59B6', '#1ABC9C', '#E67E22', '#2ECC71',
'#3498DB', '#E91E63', '#00BCD4', '#FF5722',
];
function assignColor(userId: string, collaborators: string[]): string {
const index = collaborators.indexOf(userId);
return COLLABORATION_COLORS[index % COLLABORATION_COLORS.length];
}
Cursor Position Adjustment
When a remote operation inserts or deletes text before another user's cursor, that cursor position becomes stale. The cursor must be adjusted (transformed) against incoming operations — just like operations are transformed against each other. Without this, cursors would drift to wrong positions after every remote edit.
// When a remote operation arrives, transform all other users' cursors
function transformCursor(cursor: CursorPosition, op: Operation): CursorPosition {
if (!cursor) return cursor;
// Only transform if the op affects the same text node
if (!pathEquals(cursor.path, op.path)) return cursor;
if (op.type === 'insert') {
if (op.offset <= cursor.offset) {
// Insert happened before cursor → push cursor right
return { ...cursor, offset: cursor.offset + op.content.length };
}
return cursor; // Insert after cursor → no change
}
if (op.type === 'delete') {
if (op.offset + op.length <= cursor.offset) {
// Delete happened entirely before cursor → pull cursor left
return { ...cursor, offset: cursor.offset - op.length };
}
if (op.offset < cursor.offset) {
// Delete overlaps cursor position → cursor moves to delete start
return { ...cursor, offset: op.offset };
}
return cursor; // Delete after cursor → no change
}
return cursor;
}
// Applied on every remote operation:
function handleRemoteOp(op: Operation) {
// 1. Apply op to document (as before)
// 2. Transform all remote cursors
for (const [userId, presence] of this.collaborators) {
if (userId === op.authorId) continue; // author's cursor is in their own update
presence.cursor = transformCursor(presence.cursor, op);
if (presence.selection) {
presence.selection.anchor = transformCursor(presence.selection.anchor, op);
presence.selection.focus = transformCursor(presence.selection.focus, op);
}
}
// 3. Re-render cursors at new positions
}
Presence Update Throttling
A user moving their cursor generates dozens of position changes per second (every arrow key press, every mouse movement). Sending all of these would flood the network. Instead, throttle presence updates to 2-3 per second — enough for smooth visual tracking without excessive bandwidth.
class PresenceManager {
private lastBroadcast = 0;
private pendingUpdate: UserPresence | null = null;
private throttleMs = 300; // max 3 updates/sec
updateCursor(cursor: CursorPosition) {
this.pendingUpdate = { ...this.currentPresence, cursor };
const now = Date.now();
const elapsed = now - this.lastBroadcast;
if (elapsed >= this.throttleMs) {
// Enough time passed — send immediately
this.broadcast();
} else {
// Too soon — schedule for later (debounce)
if (!this.timer) {
this.timer = setTimeout(() => this.broadcast(), this.throttleMs - elapsed);
}
}
}
private broadcast() {
if (!this.pendingUpdate) return;
this.websocket.send(JSON.stringify({
type: 'presence',
cursor: this.pendingUpdate.cursor,
selection: this.pendingUpdate.selection,
}));
this.lastBroadcast = Date.now();
this.pendingUpdate = null;
this.timer = null;
}
}
// Server-side: presence updates are NOT persisted
// They're broadcast to other clients and forgotten
// If a client reconnects, it gets current presence from the session state
// No operation log, no transformation, no versioning — just latest-wins
Activity Status Detection
Users who stop interacting with the document should transition from "active" (solid cursor) to "idle" (faded cursor) to "away" (cursor hidden). This prevents a stale cursor from misleading other collaborators into thinking someone is actively working in a section when they've actually walked away.
// Client-side activity detection
class ActivityTracker {
private lastActivity = Date.now();
private currentStatus: 'active' | 'idle' | 'away' = 'active';
onUserActivity() {
this.lastActivity = Date.now();
if (this.currentStatus !== 'active') {
this.currentStatus = 'active';
this.broadcastStatusChange('active');
}
}
// Check every 10 seconds
checkStatus() {
const idle = Date.now() - this.lastActivity;
if (idle > 300000 && this.currentStatus !== 'away') { // 5 min
this.currentStatus = 'away';
this.broadcastStatusChange('away');
} else if (idle > 60000 && this.currentStatus === 'active') { // 1 min
this.currentStatus = 'idle';
this.broadcastStatusChange('idle');
}
}
}
// Visual representation:
// Active: solid colored cursor + name label
// Idle: faded cursor + "(idle)" suffix
// Away: cursor hidden, name in collaborator list grayed out
👥 Presence vs operations — different guarantees
- Operations: exactly-once delivery, ordered, persisted, transformed. Loss = data corruption.
- Presence: at-most-once delivery, unordered, ephemeral, latest-wins. Loss = cursor flicker (harmless).
- Implication: presence can use a simpler, faster path. No ACKs, no retries, no persistence. Just broadcast and forget.
💡 Interviewer signal
Separating presence from operations — and explaining why they have different delivery guarantees — shows you understand that not everything in a real-time system needs the same consistency level. "Presence is ephemeral and lossy by design. The next update overwrites the previous one, so missing one is harmless. This lets us skip ACKs and persistence for presence, reducing server load by ~40%."
Offline Editing & Reconnection
Offline editing is what separates a toy collaborative editor from a production one. Users close their laptops, lose WiFi on trains, or work in airplane mode. The editor must continue functioning — and when connectivity returns, it must merge the offline edits with whatever changes other collaborators made in the meantime, without losing any work.
Offline Architecture
The client maintains a complete local replica of the document and an operation queue. During offline editing, operations are applied locally and stored in for crash safety. On reconnection, the buffered operations are rebased against the server's current state and submitted.
ONLINE (normal):
User types → apply locally → send to server → receive ACK → done
OFFLINE (disconnected):
User types → apply locally → store in IndexedDB → queue for later
User types → apply locally → store in IndexedDB → queue for later
... (can accumulate hundreds of operations)
RECONNECTION:
1. Connect WebSocket, send last_known_version (e.g., 4825)
2. Server responds with all ops since 4825 (e.g., versions 4826-4900 = 75 ops)
3. Client receives 75 remote ops it missed
4. Client transforms its buffered ops against the 75 remote ops
5. Client transforms the 75 remote ops against its buffered ops
6. Client applies transformed remote ops to local document
7. Client sends transformed buffered ops to server
8. Server ACKs each one, assigns versions 4901, 4902, ...
9. Sync complete — client and server are consistent
Reconnection Strategy: Bad → Good → Optimal
Bad: Discard offline edits and reload from server
The simplest approach: on reconnect, throw away local state and reload the document from the server. This guarantees consistency but loses all offline work — completely unacceptable.
// On reconnect:
async function handleReconnect() {
// Throw away everything the user did offline
const serverDoc = await fetch(`/api/v1/documents/${docId}`);
this.document = serverDoc.content;
this.version = serverDoc.version;
this.pendingOps = []; // LOST!
this.localBuffer = []; // LOST!
// User's 2 hours of offline work: gone.
// This is what happens with naive auto-save implementations.
}
Good: Rebase buffered ops against server state
On reconnect, fetch all operations that happened while offline, transform the buffered local ops against them, and submit. This preserves all offline work but can be slow if the gap is large (transforming 500 local ops against 1000 server ops = 500,000 transform calls).
async function handleReconnect() {
// 1. Get all ops we missed
const missedOps = await this.fetchOpsSince(this.lastKnownVersion);
// Could be hundreds or thousands of ops if offline for hours
// 2. Transform our buffered ops against ALL missed ops
let rebased = [...this.localBuffer];
for (const serverOp of missedOps) {
const newRebased = [];
let transformedServerOp = serverOp;
for (const localOp of rebased) {
// Transform pair: adjust both ops to account for each other
const [newServer, newLocal] = transformPair(transformedServerOp, localOp);
transformedServerOp = newServer;
newRebased.push(newLocal);
}
// Apply transformed server op to local document
this.document = applyOp(this.document, transformedServerOp);
rebased = newRebased;
}
// 3. Submit rebased local ops to server
for (const op of rebased) {
await this.submitOp(op);
}
// Problem: 500 local ops × 1000 server ops = 500,000 transform calls
// At 0.01ms per transform: 5 seconds of blocking computation
// UI freezes during rebase. Unacceptable for large offline sessions.
}
Optimal: Chunked rebase with snapshot shortcut
If the gap is small (<100 ops), rebase normally. If the gap is large, use a hybrid approach: load the server's current snapshot, diff it against the client's local document, and generate a minimal set of operations that represent the client's offline changes relative to the current server state.
async function handleReconnect() {
const gap = await this.getVersionGap();
if (gap <= 100) {
// Small gap — standard rebase (fast enough)
await this.standardRebase();
return;
}
if (gap <= 1000 && this.localBuffer.length <= 50) {
// Medium gap, few local ops — chunked rebase (non-blocking)
await this.chunkedRebase(gap);
return;
}
// Large gap or many local ops — snapshot-based merge
await this.snapshotMerge();
}
async function chunkedRebase(gap: number) {
const CHUNK_SIZE = 50;
const missedOps = await this.fetchOpsSince(this.lastKnownVersion);
// Process in chunks, yielding to UI between chunks
for (let i = 0; i < missedOps.length; i += CHUNK_SIZE) {
const chunk = missedOps.slice(i, i + CHUNK_SIZE);
this.rebaseAgainstChunk(chunk);
// Yield to UI thread (prevent freeze)
await new Promise(resolve => setTimeout(resolve, 0));
// Show progress: "Syncing... 45%"
this.updateSyncProgress(i / missedOps.length);
}
}
async function snapshotMerge() {
// 1. Get server's current document snapshot
const serverSnapshot = await this.fetchCurrentSnapshot();
// 2. Compute diff between our local document and the server snapshot
// This gives us "what did we change that the server doesn't have?"
const localChanges = computeDiff(serverSnapshot, this.document);
// 3. Replace local document with server snapshot
this.document = serverSnapshot.content;
this.lastKnownVersion = serverSnapshot.version;
// 4. Apply our local changes as new operations on top of server state
// These are already relative to the current server state (from diff)
for (const change of localChanges) {
this.document = applyOp(this.document, change);
await this.submitOp(change);
}
// Result: all offline work preserved, no O(n²) transformation
// Trade-off: diff computation is O(document_size), not O(ops)
// For a 50KB document, diff takes ~10ms — much faster than 500K transforms
}
| Strategy | Offline Work | Rebase Time (500 local, 1000 server) | Complexity |
|---|---|---|---|
| Discard and reload | ❌ Lost | 0ms (no rebase) | Trivial |
| Full rebase | ✅ Preserved | ~5 seconds (500K transforms) | Medium |
| Chunked + snapshot hybrid | ✅ Preserved | ~10-50ms (diff-based) | High |
Crash Safety: IndexedDB Persistence
If the browser crashes or the user force-quits during offline editing, in-memory operations are lost. To prevent this, every operation is written to IndexedDB before being applied locally. On restart, the client recovers from IndexedDB.
// Write to IndexedDB BEFORE applying locally
async function handleLocalEdit(op: Operation) {
// 1. Persist to IndexedDB first (crash-safe)
await this.db.put('pending_ops', {
id: this.localSeq++,
documentId: this.documentId,
operation: op,
timestamp: Date.now(),
serverVersion: this.lastKnownVersion
});
// 2. Apply to local document (now safe — recoverable from IDB)
this.document = applyOp(this.document, op);
// 3. Queue for server submission
this.localBuffer.push(op);
}
// On app startup: check for unsubmitted operations
async function recoverFromCrash() {
const pendingOps = await this.db.getAll('pending_ops');
if (pendingOps.length > 0) {
// Rebuild local document from last snapshot + pending ops
const snapshot = await this.db.get('document_snapshot', this.documentId);
this.document = snapshot.content;
for (const pending of pendingOps) {
this.document = applyOp(this.document, pending.operation);
this.localBuffer.push(pending.operation);
}
// Reconnect and submit buffered ops
await this.connect();
}
}
⚡ Conflict resolution during long offline sessions
The longer a user is offline, the more likely their edits conflict with server changes. Common scenarios:
- User edits paragraph 3 offline. Another user deletes paragraph 3 online. → On reconnect, the offline edits target a non-existent paragraph. Resolution: re-insert the paragraph with the offline content (preserve user work).
- Two users both edit the same sentence offline. → On reconnect, both edits are preserved via OT transformation. The sentence contains both users' changes (potentially awkward but no data loss).
💡 Interviewer signal
Mentioning IndexedDB for crash safety and the snapshot-based merge for large gaps shows you've thought about real-world failure modes, not just the happy path. The key insight: "offline editing is just a special case of a very long network partition — the same rebase logic handles both a 2-second WiFi blip and a 2-hour airplane mode session."
Version History & Snapshots
Version history lets users see who changed what, when, and restore to any previous state. Under the hood, it's powered by the operation log and periodic snapshots. The challenge is making history browsing fast (users expect instant preview of past versions) while keeping storage costs manageable (operation logs grow indefinitely without compaction).
Snapshot Strategy
Snapshots are full captures of the document state at a specific version. They serve two purposes: fast document loading (no need to replay the entire operation log) and log compaction (operations before the snapshot can be archived or deleted).
class SnapshotManager {
private readonly SNAPSHOT_INTERVAL = 1000; // every 1000 ops
private readonly TIME_INTERVAL = 3600000; // or every hour (whichever comes first)
private lastSnapshotVersion: number;
private lastSnapshotTime: number;
shouldCreateSnapshot(currentVersion: number): boolean {
const opsSinceSnapshot = currentVersion - this.lastSnapshotVersion;
const timeSinceSnapshot = Date.now() - this.lastSnapshotTime;
return opsSinceSnapshot >= this.SNAPSHOT_INTERVAL
|| timeSinceSnapshot >= this.TIME_INTERVAL;
}
async createSnapshot(documentId: string, document: DocumentTree, version: number) {
// Persist full document state
await db.insert('document_snapshots', {
document_id: documentId,
version: version,
content: document, // full JSON tree
created_at: new Date()
});
// Update document's current snapshot reference
await db.update('documents', documentId, {
content_snapshot: document,
snapshot_version: version
});
this.lastSnapshotVersion = version;
this.lastSnapshotTime = Date.now();
}
}
// Document loading flow:
// 1. Fetch latest snapshot (version 4000, full document tree)
// 2. Fetch operations 4001-4827 (827 ops to replay)
// 3. Apply 827 ops to snapshot → current document state
// Without snapshots: replay ALL ops from version 0 (could be millions)
Operation Log Compaction
Without compaction, the operation log grows indefinitely. A document edited for a year at 500 ops/day accumulates 182,500 operations. Compaction archives old operations while preserving the ability to view version history at meaningful granularity.
// Tiered retention policy:
// Last 24 hours: keep every individual operation (full granularity)
// Last 7 days: keep one snapshot per hour (hourly checkpoints)
// Last 30 days: keep one snapshot per day (daily checkpoints)
// Older: keep one snapshot per week (weekly checkpoints)
async function compactOperationLog(documentId: string) {
const now = Date.now();
// Phase 1: Archive ops older than 24 hours into hourly snapshots
const oldOps = await db.query(`
SELECT * FROM document_operations
WHERE document_id = $1 AND created_at < $2
ORDER BY version ASC
`, [documentId, new Date(now - 86400000)]);
// Group by hour, create snapshot at end of each hour
const hourlyGroups = groupByHour(oldOps);
for (const [hour, ops] of hourlyGroups) {
const lastOp = ops[ops.length - 1];
// Check if snapshot already exists for this version
const exists = await db.exists('document_snapshots', {
document_id: documentId,
version: lastOp.version
});
if (!exists) {
// Reconstruct document at this version and save snapshot
const doc = await reconstructAtVersion(documentId, lastOp.version);
await this.createSnapshot(documentId, doc, lastOp.version);
}
}
// Phase 2: Delete individual ops that are covered by snapshots
// Keep: ops from last 24h + ops at snapshot boundaries
await db.query(`
DELETE FROM document_operations
WHERE document_id = $1
AND created_at < $2
AND version NOT IN (SELECT version FROM document_snapshots WHERE document_id = $1)
`, [documentId, new Date(now - 86400000)]);
}
// Storage impact:
// Before compaction: 182,500 ops × 150 bytes = 27 MB per document per year
// After compaction: ~365 daily snapshots × 50 KB = 18 MB (snapshots are larger but fewer)
// Net: similar storage but MUCH faster history browsing (jump to any snapshot)
Version History UI: Browsing Past States
Users expect to click on a version in the history panel and instantly see the document at that point in time. This must be fast — loading a version should take <500ms, not seconds of replaying thousands of operations.
async function loadDocumentAtVersion(documentId: string, targetVersion: number) {
// Find the nearest snapshot BEFORE the target version
const snapshot = await db.query(`
SELECT * FROM document_snapshots
WHERE document_id = $1 AND version <= $2
ORDER BY version DESC LIMIT 1
`, [documentId, targetVersion]);
if (!snapshot) {
// No snapshot before target — must replay from beginning (slow, rare)
return replayFromBeginning(documentId, targetVersion);
}
if (snapshot.version === targetVersion) {
// Exact snapshot hit — instant load
return snapshot.content;
}
// Replay ops from snapshot to target version
const ops = await db.query(`
SELECT operation FROM document_operations
WHERE document_id = $1 AND version > $2 AND version <= $3
ORDER BY version ASC
`, [documentId, snapshot.version, targetVersion]);
// Apply ops to snapshot (at most 1000 ops if snapshots are every 1000)
let document = snapshot.content;
for (const op of ops) {
document = applyOp(document, op.operation);
}
return document;
}
// Performance:
// Best case (exact snapshot): 1 DB read, 0 replays → <50ms
// Typical case (within 1000 ops of snapshot): 1 DB read + 1000 replays → <200ms
// Worst case (no nearby snapshot): full replay → seconds (trigger async snapshot creation)
Restore to Previous Version
Restoring doesn't rewind the document — it creates a new version that contains the old content. This preserves the full history (including the restore event itself) and doesn't disrupt other collaborators who might be actively editing.
async function restoreToVersion(documentId: string, targetVersion: number) {
// 1. Load the document at the target version
const oldContent = await loadDocumentAtVersion(documentId, targetVersion);
// 2. Compute diff between current document and old content
// This generates operations that transform current → old
const currentDoc = await getCurrentDocument(documentId);
const restoreOps = computeDiff(currentDoc, oldContent);
// 3. Apply restore operations as NEW operations (not a rewind)
// This preserves history: version N+1 = "restored to version X"
for (const op of restoreOps) {
await this.submitOperation(documentId, op, {
metadata: { type: 'restore', restoredFrom: targetVersion }
});
}
// Result:
// Version history: ... → 4827 → 4828 (restore to v4000)
// The restore is a forward operation, not a rewind
// Other collaborators see the restore happen in real-time
// They can undo the restore if needed (it's just another set of ops)
}
📸 Snapshot storage optimization
Full document snapshots can be large (50-500 KB for rich documents). Optimization strategies:
- Delta snapshots — store only the diff from the previous snapshot. Reduces storage by 60-80% for incremental edits.
- Compression — JSON document trees compress well with gzip/zstd (70-80% reduction).
- Cold storage tiering — snapshots older than 30 days move to S3/blob storage. Recent snapshots stay in Postgres for fast access.
💡 Interviewer signal
Explaining that restore is a forward operation (not a rewind) shows you understand append-only architectures. "We never delete history. A restore generates new operations that transform the current document to match the old state. The history shows: edited → edited → restored to v4000. This is the same pattern as git revert vs git reset."
Comments & Annotations
Comments in a collaborative editor are anchored to specific text ranges — not to fixed positions. When the document changes (text inserted before the comment, commented text deleted), the comment anchor must move with the text it references. This is a subtle but critical challenge: a comment saying "this sentence is unclear" must stay attached to that sentence even as the document is heavily edited around it.
Comment Anchoring
A comment anchor defines the text range the comment refers to. It must be resilient to document mutations — insertions, deletions, and formatting changes around and within the anchored range should not break the association.
interface CommentAnchor {
// The text range this comment is attached to
start: {
path: Path; // path to the text node in document tree
offset: number; // character offset within the text node
};
end: {
path: Path;
offset: number;
};
// Version when the anchor was created (for reconstruction if anchor breaks)
createdAtVersion: number;
// Original anchored text (for fallback matching if positions drift)
originalText: string;
}
// Example: comment on "this sentence" in paragraph 3
{
start: { path: [3, "children", 0, "text"], offset: 5 },
end: { path: [3, "children", 0, "text"], offset: 18 },
createdAtVersion: 4500,
originalText: "this sentence"
}
Anchor Transformation
Every time an operation is applied to the document, all comment anchors must be transformed — just like cursor positions. If text is inserted before the anchor, the anchor shifts right. If the anchored text is deleted, the anchor collapses (and the comment becomes "orphaned").
function transformAnchor(anchor: CommentAnchor, op: Operation): CommentAnchor {
// Transform both start and end positions
const newStart = transformPosition(anchor.start, op);
const newEnd = transformPosition(anchor.end, op);
// Check if anchor is still valid
if (positionEquals(newStart, newEnd)) {
// Anchor collapsed — all anchored text was deleted
return { ...anchor, start: newStart, end: newEnd, status: 'orphaned' };
}
return { ...anchor, start: newStart, end: newEnd };
}
function transformPosition(pos: Position, op: Operation): Position {
// Only transform if op affects the same text node
if (!pathEquals(pos.path, op.path)) return pos;
if (op.type === 'insert' && op.offset <= pos.offset) {
return { ...pos, offset: pos.offset + op.content.length };
}
if (op.type === 'delete') {
if (op.offset + op.length <= pos.offset) {
// Delete entirely before position
return { ...pos, offset: pos.offset - op.length };
}
if (op.offset <= pos.offset) {
// Delete overlaps position — clamp to delete start
return { ...pos, offset: op.offset };
}
}
return pos;
}
// Edge case: what if the entire paragraph containing the comment is deleted?
// The path becomes invalid. Recovery strategy:
// 1. Mark comment as "orphaned" (anchor broken)
// 2. Show in comments panel with original text quote
// 3. If paragraph is restored (undo), re-attach automatically
Suggestion Mode
Suggestion mode (like Google Docs' "Suggesting" mode) proposes changes without applying them. The suggestion is stored as a special annotation that shows what would be inserted/deleted, with accept/reject controls. Under the hood, a suggestion is a comment with an attached operation that hasn't been applied yet.
interface Suggestion {
id: string;
authorId: string;
createdAt: string;
status: 'pending' | 'accepted' | 'rejected';
// The proposed change (not yet applied to document)
operation: Operation; // e.g., { type: 'delete', path: ..., offset: 5, length: 13 }
// Where in the document this suggestion appears
anchor: CommentAnchor;
// What the text would look like after accepting
proposedText: string;
// Discussion thread
comments: Comment[];
}
// Accepting a suggestion = applying its operation to the document
async function acceptSuggestion(suggestionId: string) {
const suggestion = await getSuggestion(suggestionId);
// Apply the proposed operation as a real document operation
await submitOperation(suggestion.documentId, suggestion.operation, {
metadata: { type: 'suggestion_accepted', suggestionId }
});
// Mark suggestion as accepted
await updateSuggestion(suggestionId, { status: 'accepted' });
}
// Rejecting = just marking it as rejected (no document change)
async function rejectSuggestion(suggestionId: string) {
await updateSuggestion(suggestionId, { status: 'rejected' });
}
// Rendering: suggestions are shown as inline decorations
// Deletions: strikethrough with red background
// Insertions: green background with author color underline
// Both: shown simultaneously so reviewer can see before/after
Comment Threading & Resolution
Comments support threaded replies (like a mini conversation anchored to a text range) and a resolution workflow where the author or an editor can mark a thread as "resolved" — collapsing it in the UI without deleting it. Resolved threads remain accessible for audit purposes.
interface CommentThread {
id: string;
documentId: string;
anchor: CommentAnchor;
isResolved: boolean;
// Thread of replies
comments: {
id: string;
authorId: string;
content: string; // supports @mentions and basic formatting
createdAt: string;
editedAt: string | null;
}[];
}
// Resolution workflow:
// 1. Author or editor clicks "Resolve"
// 2. Thread collapses in the margin (still visible as a dot)
// 3. Resolved threads are hidden by default, shown with a toggle
// 4. Anyone can "Re-open" a resolved thread
// Real-time: comment CRUD is broadcast to all collaborators
// But comments use a simpler sync model than document ops:
// - No OT needed (comments don't conflict with each other)
// - Simple last-write-wins for edits to the same comment
// - Anchor transformation handles position changes
💬 Comments are NOT operations
Comments live in a separate data model from document operations. They don't go through the OT engine. Adding a comment doesn't change the document content — it's metadata attached to a position. This separation means:
- Comments can't conflict with document edits
- Comment sync is simpler (no transformation needed)
- Comments can be loaded lazily (not needed for editing)
- Comment permissions can differ from edit permissions
💡 Interviewer signal
Explaining that comment anchors must be transformed against document operations — and that this is the same transform function used for cursor positions — shows you see the connection between seemingly different features. "Cursors, selections, and comment anchors are all positions in the document that must be adjusted when the document changes. They all use the same position-transformation logic."
Scaling & Reliability
Scaling a collaborative editor is fundamentally different from scaling a stateless web service. You can't just add more servers behind a load balancer — the collaboration server is stateful, holding document trees in memory. Scaling means distributing documents across servers while maintaining the invariant that all clients for one document connect to the same server.
Horizontal Scaling via Document Partitioning
Each collaboration server owns a partition of documents. A maps document IDs to server instances. When a new server is added, only a fraction of documents migrate. When a server dies, its documents are redistributed to neighbors.
Scaling model:
50M WebSocket connections across 1000 servers
Each server: ~50K connections, ~10K active documents
Memory per server: 50K × 10KB (conn state) + 10K × 210KB (doc state) = 2.6 GB
Document assignment:
hash(document_id) → position on ring → owning server
Virtual nodes: 150 per physical server (even distribution)
Adding a server (scale-up):
1. New server joins the ring
2. ~1/N documents migrate from neighbors to new server
3. Migration: load document from snapshot + recent ops
4. Clients for migrated docs receive "reconnect to new server" message
5. Clients reconnect, resume from last_known_version
Total disruption: <5 seconds per migrated document
Removing a server (scale-down or failure):
1. Server removed from ring
2. Its documents distributed to clockwise neighbors
3. New owners load documents from persistence layer
4. Clients reconnect automatically (exponential backoff)
Total recovery: <10 seconds for planned removal, <30 seconds for crash
Server Failure & Recovery
When a collaboration server crashes, all documents it owned become temporarily unavailable. The recovery process must be fast (users are staring at a "Reconnecting..." spinner) and correct (no operations lost).
// Detection: health check fails (2 missed heartbeats = 60 seconds)
// For faster detection: clients report "connection lost" to gateway
async function handleServerCrash(failedServer: ServerInstance) {
// 1. Remove from hash ring
hashRing.removeNode(failedServer);
// 2. Identify affected documents
const affectedDocs = getDocumentsOnNode(failedServer);
// Typically 5K-10K documents per server
// 3. Assign to new owners (next on ring)
for (const docId of affectedDocs) {
const newOwner = hashRing.getNode(docId);
// 4. New owner loads document state
await newOwner.loadDocument(docId);
// Load latest snapshot + replay ops since snapshot
// Typically <500ms per document
}
// 5. Clients auto-reconnect to gateway
// Gateway routes them to new owner based on updated ring
// Client sends last_known_version → server sends missed ops → resume
}
// What about in-flight operations that were received but not persisted?
// The persistence service flushes every 500ms.
// Worst case: last 500ms of operations are lost.
// Recovery: clients have these ops in their pending queue.
// On reconnect, they re-submit pending ops → server processes them again.
// Deduplication via sequence numbers prevents double-application.
// Net data loss: ZERO (clients hold all unACKed ops)
// Disruption: 10-30 seconds of "Reconnecting..." for affected documents
Hot Document Problem
Most documents have 1-5 collaborators. But occasionally, a document goes "hot" — a company all-hands doc with 200 concurrent editors, or a public wiki page with 500 viewers. A single collaboration server can handle ~50 ops/sec per document, but 200 active editors generate ~1600 ops/sec with fan-out of 199 per op.
// Strategy 1: Dedicated server for hot documents
// When a document exceeds 50 concurrent editors, migrate it to a dedicated
// high-memory server with no other documents competing for resources.
// Strategy 2: Read replicas for viewers
// Viewers (read-only) don't need to be on the same server as editors.
// Route viewers to read replicas that receive operation broadcasts.
class ViewerReplicaRouter {
route(userId: string, documentId: string, permission: string) {
if (permission === 'viewer') {
// Route to a read replica (receives ops via pub/sub, no write path)
return this.getViewerReplica(documentId);
}
// Editors go to the primary collaboration server
return this.getPrimaryServer(documentId);
}
}
// Strategy 3: Block-level partitioning for very large documents
// Split a 100-page document into blocks. Each block can be edited independently.
// Conflicts only happen within a block, not across blocks.
// Different blocks can be owned by different servers.
// This is how Notion works — each block is an independent CRDT.
// Strategy 4: Presence fan-out offloading
// For 200 editors, presence updates = 200 × 3/sec × 199 fan-out = 119K msgs/sec
// Offload presence to a separate lightweight pub/sub service
// Presence doesn't need OT — just latest-wins broadcast
Persistence Reliability
Operations must be durably persisted even if the collaboration server crashes. The persistence path uses a write-ahead approach: operations are buffered in memory and flushed to Postgres in batches every 500ms. For additional safety, operations are also written to a as a durable buffer before Postgres.
Operation persistence path:
1. Op processed by collaboration server (in-memory)
2. Immediately written to Redis Stream (durable buffer, <1ms)
3. Batch flushed to Postgres every 500ms (or every 50 ops)
4. On successful Postgres write, trim Redis Stream entries
If collaboration server crashes:
- Redis Stream has all ops not yet in Postgres
- New owner reads from Redis Stream to catch up
- Then resumes normal operation
If Redis crashes:
- Collaboration server still has ops in memory
- Falls back to direct Postgres writes (slower but safe)
- Redis recovers from AOF persistence
If Postgres is down:
- Ops accumulate in Redis Stream (can buffer hours of ops)
- Collaboration continues normally (in-memory + Redis)
- When Postgres recovers, flush backlog
Result: zero data loss under any single-component failure
| Failure | Impact | Recovery Time | Data Loss |
|---|---|---|---|
| Collaboration server crash | Affected docs show 'Reconnecting' | 10-30 seconds | Zero (clients re-submit pending ops) |
| Redis failure | Persistence buffer lost | 5 seconds (failover) | Zero (server has ops in memory) |
| Postgres failure | No new snapshots, ops buffer in Redis | 30-60 seconds (failover) | Zero (Redis Stream buffers) |
| Network partition (server isolated) | Clients reconnect to new owner | 60 seconds (detection + recovery) | Zero (clients hold unACKed ops) |
📊 Key operational metrics
- Op processing latency p99 — should be <5ms. If spiking, server is overloaded (too many documents).
- Persistence lag — time between op processed and op in Postgres. Should be <1s. If growing, Postgres is slow.
- WebSocket connection count per server — alert at 80% capacity (40K of 50K max).
- Document memory usage — alert if any single document exceeds 10MB (likely a hot doc needing migration).
- Client reconnection rate — spike indicates server instability or network issues.
💡 Interviewer signal
Explaining that zero data loss is achieved through client-side buffering (not just server-side replication) is a key insight. "The client holds all unACKed operations. If the server crashes, the client simply re-submits them to the new owner. The server doesn't need synchronous replication for durability — the clients ARE the replication."
Trade-offs Consolidated
Every decision in this design was a trade. Bundling them in one place makes the reasoning easy to review — and gives a candidate a compact story to walk the interviewer through at the end.
| Decision | We Picked | Why | What We Gave Up |
|---|---|---|---|
| Conflict resolution | OT (server-authoritative) | Lower memory per document; proven at Google Docs scale; server already needed for persistence | Complex transform functions; harder offline support; single-server bottleneck per document |
| Server architecture | Stateful collaboration servers | Sub-millisecond transform; no DB reads on hot path; simple sequential versioning | Server affinity required; failover complexity; can't load-balance freely |
| Document routing | Consistent hashing by document_id | Minimal redistribution on scale events; deterministic routing; O(1) lookup | Hot documents stuck on one server; rebalancing needed for uneven load |
| Operation buffer | Bounded (1000 ops) + snapshot fallback | Bounded memory; fast transform for recent ops; clean reset for stale clients | Clients offline >1000 ops must reload (rare but jarring UX) |
| Persistence model | Async flush (500ms) via Redis Stream → Postgres | Doesn't block real-time path; durable buffer survives crashes; batch efficiency | 500ms persistence lag; complex recovery path; dual-write to Redis + Postgres |
| Snapshot frequency | Every 1000 ops or 1 hour | Fast document loading (max 1000 ops to replay); manageable storage | Snapshot creation is expensive (serialize full tree); storage for large docs |
| Presence delivery | At-most-once, no persistence | Simple, fast, low overhead; missing an update is harmless | Cursor may flicker on packet loss; no presence history |
| Offline sync | Chunked rebase + snapshot merge for large gaps | Preserves all offline work; non-blocking UI; handles any gap size | Complex implementation; diff computation for large gaps; edge cases in merge |
| Comment anchoring | Position-based with transformation | Anchors move with text naturally; same transform logic as cursors | Anchors can break if entire paragraph deleted; needs orphan handling |
| WebSocket delivery | Exactly-once via seq numbers + server dedup | No lost ops, no duplicates; clean recovery on reconnect | Server must track per-client sequence state; slightly more memory |
| Hot document handling | Viewer replicas + block-level partitioning | Scales reads independently; reduces fan-out on primary; Notion-proven pattern | Added complexity; cross-block operations need coordination; eventual consistency for viewers |
Where reasonable engineers disagree
💬 OT vs CRDT — the great debate
Google Docs uses OT (2006). Figma uses CRDTs (2019). Both work at massive scale. OT is simpler on the server but complex in the transform functions. CRDTs are simpler conceptually but use more memory and have garbage collection challenges. For a new system in 2026, CRDTs (via Yjs or Automerge) are increasingly the default choice — the libraries are mature and offline support is free.
💬 Stateful server vs stateless + Redis
Some teams put all document state in Redis and keep the collaboration server stateless. This simplifies scaling (any server can handle any document) but adds 1-2ms latency per operation (Redis round-trip for every transform). At 50 ops/sec per document, that's 50 Redis calls/sec per active doc. Viable at moderate scale; questionable at Google Docs scale.
💬 Block-based vs free-form document model
Notion uses blocks (each paragraph/heading/table is an independent unit). Google Docs uses a continuous document stream. Blocks reduce conflict surface (edits in different blocks never conflict) but limit formatting flexibility (can't have a sentence that spans two blocks with different styles). The choice depends on product requirements.
💬 500ms persistence flush vs synchronous write
We chose async persistence (500ms flush) for performance. The counter-argument: if the server AND Redis both crash within 500ms, you lose those operations. The response: clients hold unACKed ops and re-submit on reconnect, so data loss is zero regardless. The 500ms flush is an optimization, not a durability compromise.
🎯 The trade-off that defines seniority
The biggest divide between junior and senior answers is whether the candidate can articulate why the collaboration server is stateful by design: "Stateless is the default for web services, but real-time OT requires sub-millisecond access to recent operations for transformation. A database round-trip per operation would add unacceptable latency at 50 ops/sec. The trade-off is failover complexity — which we handle through consistent hashing, client-side buffering, and snapshot-based recovery."
Follow-ups & Common Traps
The last 10 minutes of the interview are where candidates separate. The interviewer stops nodding along and starts probing: "what if...?", "how would you...?", "what breaks when...?". These are the questions worth pre-loading.
Curveball follow-ups
Q:What happens if the OT server crashes mid-operation? How do you recover?
A: The server holds document state in memory, but clients hold all unACKed operations. On crash: (1) consistent hash ring routes the document to a new server, (2) new server loads latest snapshot + replays ops from Redis Stream, (3) clients reconnect and re-submit their pending ops. The sequence number dedup ensures no double-application. Net data loss: zero. Recovery time: 10-30 seconds. The key insight: clients ARE the replication layer for unACKed ops.
Q:How do you handle a user who's been offline for a week with a stale document?
A: Their client_version is thousands of ops behind. The bounded buffer (1000 ops) can't cover the gap. We use the snapshot merge strategy: (1) fetch server's current snapshot, (2) compute diff between the user's local document and the server snapshot, (3) apply the diff as new operations on top of current server state. This preserves all offline work without O(n²) transformation. The diff computation is O(document_size) — typically 10-50ms for a normal document.
Q:Can you support real-time collaboration across regions with <100ms latency?
A: Not with a single authoritative server. OT requires a central ordering point — if the server is in US-East and the user is in Singapore, that's 200ms RTT minimum. Options: (1) Accept higher latency for remote users (local edits are still instant, only remote ops are delayed). (2) Use CRDTs instead of OT — no central server needed, each region can have a local replica that merges asynchronously. (3) Hybrid: regional collaboration servers that sync via CRDT, with OT within each region. Google Docs uses option 1 — local edits are instant, remote edits arrive with network delay.
Q:How does the system handle a document with 10,000 pages of content?
A: A 10,000-page document is ~5MB of text content, ~50MB as a CRDT/OT document tree. This exceeds comfortable in-memory size. Solution: block-level partitioning (Notion model). The document is split into blocks (paragraphs, headings, tables). Only blocks currently visible in the viewport are loaded into the collaboration session. Edits in block 5000 don't require loading blocks 1-4999. Each block is an independent collaboration unit. Cross-block operations (move paragraph from page 1 to page 50) are handled as delete + insert across two block sessions.
Q:What's the difference between how Notion and Google Docs handle collaboration?
A: Google Docs: continuous document stream, OT-based, server-authoritative, single collaboration session per document. Notion: block-based, CRDT-inspired, each block is independent, supports offline natively. The architectural difference: Google Docs treats the document as one unit (all ops go through one server). Notion treats each block as a separate entity (blocks can be synced independently, moved between pages, embedded elsewhere). Notion's model scales better for very large documents but limits cross-block formatting.
Q:How do you garbage-collect the operation log without losing undo history?
A: Tiered compaction: (1) Last 24 hours: keep every individual op (full undo granularity). (2) Last 7 days: keep hourly snapshots (can restore to any hour). (3) Older: keep daily/weekly snapshots. Undo within a session uses the client's local undo stack (not the server log). Cross-session undo ('undo what I did yesterday') uses the operation log — which is why we keep full granularity for 24 hours. After 24 hours, 'undo' becomes 'restore to version X' using snapshots.
Q:How do you handle permissions changes in real-time? User A revokes User B's edit access while B is typing.
A: Permission changes are broadcast as a special message type on the WebSocket. When B's client receives 'permission_changed: viewer', it: (1) disables the editor (read-only mode), (2) discards any pending unsubmitted ops (they'd be rejected by the server anyway), (3) shows a notification: 'Your access has been changed to view-only.' Server-side: the collaboration server checks permissions on every incoming operation. If B's op arrives after the permission change, it's rejected with a 'permission_denied' error. No race condition — server is the authority.
Q:How would you implement 'Track Changes' (showing who changed what)?
A: Every operation in the log has an author_id and timestamp. To show 'Track Changes': (1) load the operation log for the desired time range, (2) group consecutive ops by author, (3) render insertions with the author's color background and deletions with strikethrough. For the 'accept/reject' workflow: accepting = no-op (the change is already applied). Rejecting = generate an inverse operation that undoes the change. This is the same as suggestion mode but applied retroactively to already-committed operations.
Q:What if two users simultaneously create a comment on overlapping text ranges?
A: Comments don't conflict with each other — they're metadata, not document content. Two comments on the same text range simply coexist (both are shown in the margin). The only conflict is visual: if two comment highlights overlap, the UI must render them as nested or stacked highlights. This is a rendering problem, not a consistency problem. Comments are synced via simple CRUD broadcast, not OT.
Q:How do you handle copy-paste of a large block (10,000 characters)?
A: A paste of 10,000 characters is a single composite operation, not 10,000 individual inserts. The client batches the entire paste into one atomic operation: { type: 'composite', operations: [{ type: 'insert', content: '10000 chars...' }] }. This is sent as one WebSocket message (~10KB), transformed as one unit, and applied atomically. Undo reverses the entire paste in one step. The operation log records one entry, not 10,000.
Common traps (and how to avoid them)
Using locks to prevent conflicts
'Lock the paragraph while someone is editing it.' This prevents collaboration entirely — the whole point is concurrent editing without locks.
✅Use OT or CRDTs to resolve conflicts after they happen, not prevent them. Locks are antithetical to real-time collaboration.
Sending the full document on every change
'On each edit, send the entire document to the server.' A 50KB document at 8 edits/sec = 400 KB/sec per user. With 20 users: 8 MB/sec per document.
✅Send only the operation (delta): { type: 'insert', position: 12, content: 'H' } — typically 100-200 bytes regardless of document size.
Waiting for server ACK before showing the edit
User types 'H', waits 100ms for server response, then sees 'H' appear. This makes the editor feel laggy and unusable.
✅Optimistic local apply: show the edit instantly, send to server in background. If server rejects (rare), undo locally. Users should never perceive network latency during typing.
Treating the collaboration server as stateless
'Just put document state in Redis and make the server stateless.' Every operation now requires a Redis round-trip (1-2ms). At 50 ops/sec: 50 Redis calls/sec per document.
✅Accept that the collaboration server is stateful by design. Use consistent hashing for routing, snapshot-based recovery for failover. The performance gain (0.01ms vs 2ms per op) justifies the operational complexity.
Ignoring the client-side transform
Only transforming on the server. When a remote op arrives at the client, applying it directly without transforming against pending local ops.
✅The client must transform incoming remote ops against its pending (unACKed) local ops. Otherwise the remote op is applied at the wrong position relative to the client's current state.
Synchronous persistence on every operation
Writing every operation to Postgres before ACKing the client. At 50 ops/sec: 50 DB writes/sec per document. Adds 5-10ms latency per operation.
✅Async persistence: buffer ops in memory, flush to Postgres every 500ms in batches. Use Redis Stream as durable buffer for crash safety. ACK the client from memory, not from disk.
Unbounded operation log in memory
Keeping all operations since document creation in the server's memory for transformation. A document with 1M ops: 150MB just for the log.
✅Bounded buffer (last 1000 ops). Clients with gaps >1000 get a full snapshot reset. This bounds memory at ~150KB per document regardless of history length.
No tombstone handling in CRDTs
If using CRDTs: deleted characters leave tombstones that accumulate forever. A document with 1M total edits (including deletes) has 1M CRDT entries even if the visible text is only 1000 characters.
✅Periodic garbage collection: when all clients have seen a deletion, the tombstone can be removed. Requires tracking 'minimum version seen by all clients' — similar to Kafka consumer group offsets.
🔥 The deepest trap — overcomplicating the transform function
The OT transform function for rich text has dozens of cases (insert×insert, insert×delete, insert×format, delete×format, split×merge, move×delete...). Candidates who try to enumerate all cases on the whiteboard will run out of time. The right move: explain the concept with 2-3 simple cases (insert×insert, insert×delete), state that production implementations handle ~20 cases, and reference that Google's OT library took years to get right. Then move on to architecture.
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale anchors: 50M WebSocket connections, 10M active docs, 30-50M ops/sec platform-wide, ~8 ops/sec per active user.
Core algorithm: OT (Operational Transformation): server transforms concurrent ops to adjust positions. Guarantees convergence.
Why OT over CRDT: Lower memory (no per-char IDs/tombstones). Server already needed for persistence. Proven at Google Docs scale.
Server architecture: Stateful collaboration server. Document tree + recent ops in memory. Consistent hashing routes by doc_id.
Operation flow: Client: apply locally → send op. Server: transform → assign version → ACK sender → broadcast to others.
Client-side: Optimistic apply (instant). Pending queue (sent, awaiting ACK). Buffer (not yet sent). Transform remote ops against both.
Delivery guarantee: Exactly-once via sequence numbers + server-side dedup. Lost op → client retries. Lost ACK → server deduplicates.
Presence: Ephemeral, at-most-once, throttled to 3/sec. Cursors transformed against ops. No persistence needed.
Offline support: Buffer ops in IndexedDB. On reconnect: small gap → rebase. Large gap → snapshot merge (diff-based).
Persistence: Async flush every 500ms. Redis Stream as durable buffer. Postgres for operation log + snapshots.
Snapshots: Every 1000 ops or 1 hour. Enables fast loading (max 1000 ops to replay) and log compaction.
Version history: Tiered: full ops (24h) → hourly snapshots (7d) → daily (30d) → weekly (older). Restore = forward op, not rewind.
Failover: Server crash → hash ring reroutes → new owner loads from snapshot + Redis Stream. Clients re-submit pending ops. Zero data loss.
Hot documents: Viewer replicas for read-only users. Block-level partitioning for very large docs. Dedicated server for 200+ editors.
Comments: Anchored to text ranges. Anchors transformed against ops (same as cursors). Separate from document ops (no OT needed).
Key SLOs: Local edit: 0ms. Remote edit visible: <200ms. Document load: <500ms. Reconnection: <30s.
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements. Ask: rich text or plain? How many concurrent editors? Offline needed? Block-based?
- 5–10 min: Capacity estimation. Derive ops/sec, fan-out cost, WebSocket connections, storage growth.
- 10–15 min: API + protocol design. REST for CRUD, WebSocket for ops. Show message format with client_version.
- 15–25 min: HLD — stateful collab server, consistent hashing, persistence pipeline. Explain the operation flow end-to-end.
- 25–35 min: Deep dive — likely OT vs CRDT or offline sync. Walk through the conflict scenario with concrete example.
- 35–40 min: Trade-offs. OT vs CRDT, stateful vs stateless, bounded buffer, persistence lag.
- 40–45 min: Follow-ups. Server crash recovery, long offline, hot documents, cross-region.
💡 The single sentence that defines a senior answer
"This is a distributed consensus problem: multiple clients maintain local replicas and must converge despite concurrent edits. OT achieves this through server-authoritative ordering and position transformation. The server is stateful by design for sub-millisecond transforms, with consistent hashing for routing and client-side buffering for zero-data-loss failover." If you can say this and back each claim, you're answering at the right level.