Design a Collaborative Editor (Google Docs)
Design a real-time collaborative text editor where multiple users type into the same document simultaneously and everyone converges to the same content. The heart of the problem is concurrent edit reconciliation (operational transformation vs CRDTs), plus cursor presence, version history, and offline editing.
1Requirements
Functional
- • Multiple users edit the same document concurrently and all replicas converge to identical content.
- • Edits from one user appear on other users' screens within a few hundred milliseconds.
- • Show each collaborator's cursor position and selection in real time with a name label.
- • Maintain version history with the ability to view and restore past snapshots.
- • Support offline editing: queued local edits sync and merge when connectivity returns.
- • Per-document access control (owner, editor, viewer).
Non-functional
- • Edit propagation latency under 300 ms for collaborators in the same region.
- • Convergence is non-negotiable: all replicas must reach the same state regardless of message ordering (within the protocol's delivery guarantees).
- • Local typing must never block on the network; edits apply optimistically at the local replica.
- • Support up to ~100 concurrent editors per document; scale to millions of documents overall.
- • Durability: an acknowledged edit survives server crashes (persisted op log).
- • Session reconnects must resume cleanly from the last acknowledged revision.
2Back-of-envelope estimation
| Ops per document-second | ~25 ops/sec | 5 active typists x 5 keystrokes/sec; trivial per document, the challenge is correctness, not volume. |
| Op message size | ~100 bytes | Op type + position + character + revision + author; 25 ops/sec x 100 B = 2.5 KB/sec per hot document. |
| Fan-out bandwidth | ~250 KB/sec per hot doc | 2.5 KB/sec x 100 connected clients; one WebSocket server core handles hundreds of hot documents. |
| Op log growth | ~9 MB/hour of active editing | 25 ops/sec x 100 B x 3600; snapshot every 1,000 ops and archive older ops to keep replay under 100 ms. |
| Platform scale | ~50k concurrent hot docs | 10M docs with 0.5% concurrently active; shard by document id so each doc's ops serialize through one server. |
3API design
WS /docs/{docId}/connect?rev=1042WebSocket session for a document. Client sends its last known revision; server replays missed ops, then streams live ops, acks, and presence updates.
WS message: {type:'op', baseRev, ops:[...]}Client submits an edit based on revision baseRev. Server transforms it against concurrent ops, assigns the next revision, acks the author, and broadcasts to others.
WS message: {type:'cursor', pos, selEnd}Ephemeral presence update; broadcast to peers, throttled to ~10/sec, never persisted.
GET /docs/{docId}/snapshot?rev=900Returns the document content at a revision, materialized from the nearest stored snapshot plus op replay; powers history view and restore.
POST /docs/{docId}/restoreRestores an old version by appending inverse ops as a new edit, preserving the full history rather than rewriting it.
4High-level design
The naive approach fails immediately: if Alice inserts at position 5 while Bob deletes at position 2, applying their raw operations in different orders yields different documents on each replica. Every collaborative editor is an answer to this concurrency problem, and the two established answers are operational transformation (OT) and conflict-free replicated data types (CRDTs).
OT, the Google Docs approach, keeps operations position-based (insert 'x' at 5) and transforms them against concurrent operations before applying: if Bob's delete at 2 arrives first, Alice's insert shifts to position 4. OT is dramatically simpler when a central server serializes all operations into one canonical order: each client tracks the last server revision it has seen, sends ops against that revision, and the server transforms incoming ops over anything it accepted since. Clients symmetrically transform their unacknowledged local ops over incoming remote ops.
CRDTs instead give every character a permanent unique identity (say, an author-counter pair plus a reference to its left neighbor), so operations commute by construction and no transformation or central sequencer is needed. This makes offline merge and peer-to-peer sync natural, at the price of per-character metadata and tombstones for deleted text. Modern implementations (Yjs, Automerge) compress the overhead well enough that CRDTs are now the default for new projects, while OT survives in systems that already have a central server and want minimal payloads.
The serving architecture: each document is owned by exactly one WebSocket server (route by hashing document id at a connection gateway), which holds the document's hot state, orders or merges ops, appends them to a persisted op log, and broadcasts to subscribers. Snapshots every N ops bound recovery and history-replay time. If the owner dies, another server reloads snapshot plus op-log tail and clients reconnect with their last acked revision.
Presence (cursors, selections, who's online) rides the same WebSocket but is ephemeral: throttled, broadcast, never written to the log. Cursor positions must be mapped through the same transform/identity machinery as text, otherwise remote cursors drift as text changes around them. With CRDTs this is elegant: a cursor is just a reference to a character id, so it survives any remote edit automatically.
5Data model
documents
doc_id, owner_id, title, current_rev, latest_snapshot_rev, created_at, updated_atcurrent_rev is the head of the op log; routing hashes doc_id to a WebSocket server.
ops
doc_id, rev, author_id, op_json, created_atPK (doc_id, rev). The append-only source of truth; op_json holds insert/delete payloads.
snapshots
doc_id, rev, content_blob, created_atMaterialized every 1,000 ops; any revision = nearest earlier snapshot + replay of ops in between.
doc_acl
doc_id, user_id, role, granted_by, created_atRole in (owner, editor, viewer); checked at WebSocket connect and on every mutating op.
6Deep dives
OT vs CRDT: the real tradeoff
OT's operations are small and human-readable (insert at index, delete range), the persisted log is compact, and intention preservation (what should happen when edits collide) is encoded explicitly in the transform functions. Its weakness is that correctness is notoriously subtle: transform functions must satisfy convergence properties (TP1, and TP2 for serverless topologies), and several published algorithms were later shown to violate them. Practical systems avoid the hard case entirely by forcing all ops through one server that defines a total order, which is exactly what Google Docs does.
CRDTs move the cleverness from the algorithm to the data structure: each character carries an identity and ordering metadata, so concurrent inserts at the same place are ordered deterministically by comparing ids. Convergence is guaranteed by construction, offline and peer-to-peer merging need no special machinery, and there is no central sequencer requirement. The costs are metadata overhead, tombstones that must be retained or carefully garbage-collected, and interleaving anomalies in naive designs (two users' concurrent sentences shuffling character-by-character) that mature libraries mitigate.
Interview guidance: OT if you have a central server anyway and want minimal storage and precise intention control; CRDT if offline-first, P2P, or implementation safety matters more, since a library like Yjs gives you proven convergence out of the box. Saying 'CRDT with a central relay server' is a perfectly modern answer that gets the best of both.
The server-serialized OT protocol
The protocol that makes OT tractable has each client maintain three things: the last server revision it has synced to, at most one op in flight awaiting ack, and a buffer of local edits composed while waiting. The client applies local edits immediately (zero-latency typing), sends the in-flight op tagged with its base revision, and composes any further typing into the buffer.
The server holds the canonical op log. When an op arrives based on revision R but the log is at R+k, the server transforms the op over those k concurrent ops, appends the result as revision R+k+1, acks the author, and broadcasts to everyone else. Receiving clients transform the incoming op over their own in-flight and buffered ops before applying, and symmetrically transform their pending ops over it, so both sides account for each other exactly once.
This one-in-flight-op discipline (used by Google Wave and every derivative) matters: it bounds the transformation cases the client must handle and makes recovery simple. On reconnect, the client sends its last acked revision, receives the ops it missed, transforms its pending buffer over them, and resumes. Every message carries the revision number, which doubles as the idempotency key against duplicate delivery.
Offline edits and long-lived divergence
Online, concurrent windows are milliseconds; offline, a user may accumulate hours of edits against a stale base revision. With OT, reconciliation means transforming the entire offline batch over every op the server accepted meanwhile, which is O(offline ops x missed ops) transform calls. It works, but a thousand offline edits against ten thousand missed ops is ten million transforms, so implementations compose offline edits into a compact form first and cap how stale a base revision can be before forcing a manual merge.
CRDTs treat offline as the normal case: the offline replica just merges with the server state like any other sync, and convergence is automatic regardless of divergence duration. State-based sync with version vectors lets the two sides exchange only the ops the other has not seen. This asymmetry is the single strongest argument for CRDTs in any product where offline is a first-class feature.
Either way, converged is not the same as semantically sensible: two users independently rewriting the same paragraph will merge into interleaved text that neither intended. Good products surface large offline merges to the user (show a diff, keep both versions in history) rather than pretending the algorithm resolved the human conflict.
Versioning, snapshots, and history
The append-only op log is the natural spine for version history: any revision is reproducible as snapshot(rev <= r) plus replay of ops up to r. Snapshots every 1,000 ops (or every few minutes of activity) bound both crash recovery and history rendering to a bounded replay. Old ops can be compacted into coarser summary snapshots after 30 days if per-keystroke history is not required forever.
Restore must not rewrite history: restoring revision 900 at head revision 1200 appends new ops that transform the head content into the revision-900 content (or with CRDTs, applies a computed diff as fresh edits). History stays linear and auditable, and a restore can itself be undone.
Attribution falls out for free since every op carries its author: the history view can replay ops and color spans by author, and per-user undo works by inverting only your own ops and transforming the inverse over everything applied since, which is exactly how collaborative undo is built.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Node.js + ws WebSocket server + Postgres op log, plain textarea client with vanilla TypeScript OT; runs on one $6 VPS.
- 01Create the docs, ops (PK doc_id + rev), and snapshots tables in Postgres.
- 02Define the op format as JSON: {retain n, insert 'text', delete n} segments over the whole document, plus baseRev and author.
- 03Implement transform(opA, opB) for insert/insert, insert/delete, and delete/delete cases with unit tests asserting both application orders converge.
- 04Build the WebSocket server: on connect, send snapshot plus ops since the client's revision; on op receipt, transform over concurrent ops, INSERT the op row, ack the author with the new rev, broadcast to others.
- 05Build the client sync loop: apply local edits to the textarea immediately, keep one op in flight, compose further edits into a buffer, transform pending ops over incoming remote ops.
- 06Map remote cursors: broadcast {pos} presence messages at most 10/sec and shift each remote cursor through the same transform as text.
- 07Add snapshotting every 200 ops and a /history page that replays ops from the nearest snapshot with a revision slider.
- 08Torture test: two headless clients firing random concurrent edits for 10,000 ops, assert final texts are byte-identical.
OT transform for concurrent insert/delete
typescripttype Op =
| { type: "insert"; pos: number; text: string; author: string }
| { type: "delete"; pos: number; len: number; author: string };
// Transform opA so it applies correctly AFTER opB has been applied.
export function transform(a: Op, b: Op): Op {
if (b.type === "insert") {
const shift = b.text.length;
if (a.type === "insert") {
// Tie at same position: lower author id goes first (deterministic on all replicas)
const aFirst = a.pos < b.pos || (a.pos === b.pos && a.author < b.author);
return aFirst ? a : { ...a, pos: a.pos + shift };
}
if (a.pos >= b.pos) return { ...a, pos: a.pos + shift };
if (a.pos + a.len <= b.pos) return a;
return { ...a, len: a.len + shift }; // b inserted inside a's delete range
}
// b is a delete
const bEnd = b.pos + b.len;
if (a.type === "insert") {
if (a.pos <= b.pos) return a;
if (a.pos >= bEnd) return { ...a, pos: a.pos - b.len };
return { ...a, pos: b.pos }; // a's insertion point was deleted
}
const aEnd = a.pos + a.len;
if (aEnd <= b.pos) return a;
if (a.pos >= bEnd) return { ...a, pos: a.pos - b.len };
const overlap = Math.min(aEnd, bEnd) - Math.max(a.pos, b.pos);
return { ...a, pos: Math.min(a.pos, b.pos), len: a.len - overlap };
}Server: serialize, transform, ack, broadcast
typescriptasync function handleClientOp(doc: DocState, client: Client, msg: { baseRev: number; op: Op }) {
let op = msg.op;
// Transform over every op accepted since the client's base revision
const concurrent = doc.log.slice(msg.baseRev); // log[i] produced rev i+1
for (const prior of concurrent) op = transform(op, prior.op);
const rev = doc.log.length + 1;
doc.content = applyOp(doc.content, op);
doc.log.push({ rev, op, author: client.userId });
await db.query(
"INSERT INTO ops (doc_id, rev, author_id, op_json) VALUES ($1, $2, $3, $4)",
[doc.id, rev, client.userId, JSON.stringify(op)]
);
client.send({ type: "ack", rev }); // author advances baseRev
for (const peer of doc.clients) {
if (peer !== client) peer.send({ type: "op", rev, op }); // others transform locally
}
if (rev % 200 === 0) await saveSnapshot(doc.id, rev, doc.content);
}Client: one op in flight, compose while waiting
typescriptclass ClientSync {
rev = 0; // last server revision synced
inflight: Op | null = null; // sent, awaiting ack
buffer: Op[] = []; // local edits composed while waiting
localEdit(op: Op) {
applyToEditor(op); // optimistic: typing never waits on the network
this.buffer.push(op);
this.flush();
}
private flush() {
if (this.inflight || this.buffer.length === 0) return;
this.inflight = this.buffer.shift()!;
ws.send(JSON.stringify({ type: "op", baseRev: this.rev, op: this.inflight }));
}
onServerMessage(msg: any) {
if (msg.type === "ack") { this.rev = msg.rev; this.inflight = null; this.flush(); return; }
// Remote op: transform it over our pending ops, and our pending ops over it
let remote: Op = msg.op;
const pending = [this.inflight, ...this.buffer].filter((o): o is Op => o !== null);
for (let i = 0; i < pending.length; i++) {
const mine = pending[i];
pending[i] = transform(mine, remote);
remote = transform(remote, mine);
}
if (this.inflight) this.inflight = pending[0];
this.buffer = pending.slice(this.inflight ? 1 : 0);
applyToEditor(remote);
this.rev = msg.rev;
}
}Bottlenecks & failure modes
- ⚠Per-document ordering serializes through a single owner server; a doc with hundreds of editors is a hard ceiling (mitigate by throttling, batching ops, or splitting the doc).
- ⚠OT transform storms on reconnect after long offline periods; requires op composition and staleness caps.
- ⚠CRDT tombstone and metadata growth in long-lived heavily edited documents; needs GC once all replicas have seen a deletion.
- ⚠Unthrottled cursor presence traffic can exceed the actual edit traffic; throttle to 10 updates/sec and coalesce.
- ⚠WebSocket server failover loses in-memory doc state; op-log persistence plus client resume-from-revision must be airtight or edits vanish.
Key takeaways
- ▸State the core problem first: concurrent position-based edits do not commute, so you need OT (transform to a canonical order) or CRDT (make ops commute by giving characters identities).
- ▸A central server makes OT simple: one canonical op order, clients keep one op in flight and transform the rest.
- ▸CRDTs trade metadata overhead for guaranteed convergence and effortless offline merge; Yjs-style libraries make this the pragmatic modern default.
- ▸The op log is the product: it gives you durability, history, restore, attribution, and undo in one structure.
- ▸Convergence is a data-structure property; resolving human intent conflicts is a product decision, so surface big merges instead of hiding them.