5 min readRishi

Designing Google Docs: Real-Time Collaborative Editing with OT and CRDTs

Two users have the document "cat". User A inserts "s" at position 3 to make "cats". Simultaneously, user B deletes the character at position 1 to make "at". If you apply both operations naively in different orders on different replicas, the replicas diverge — one machine shows "ats", the other shows "ast" or worse. Every collaborative editor is, at its core, a machine for preventing exactly this.

This is one of the hardest system design interviews because the crux is not scaling — it is correctness under concurrency. Let us design it properly.

Requirements and the real constraint

Functional: multiple users edit the same document simultaneously, see each other's cursors, and every replica converges to the same content. Non-functional: keystroke-to-visible latency under ~100ms for a good feel, documents up to a few MB, sessions of 2-100 concurrent editors.

The real constraint: convergence. All replicas must reach identical state regardless of the order in which they received concurrent operations. Bandwidth and QPS are trivial by comparison — a fast typist emits ~8 ops/second of a few dozen bytes each.

Approach 1: Operational Transformation (OT)

OT is what Google Docs actually uses. The idea: when two operations happened concurrently, transform one against the other so the intent is preserved.

A: insert("s", pos=3)   B: delete(pos=1)

B applies A's op unchanged:        "at"  -> insert at 3?  transform!
transform(insert@3, delete@1) = insert@2   ->  "ats"
A applies B's op unchanged:        "cats" -> delete@1     ->  "ats"

Both sides converge on "ats". The transform function T(op1, op2) must satisfy the TP1 property: applying op1 then T(op2, op1) equals applying op2 then T(op1, op2).

Writing correct transforms for insert/delete pairs is a weekend project. Writing them for rich text — formatting spans, tables, embedded objects — is years of edge cases. This is why almost everyone with a choice today picks a library (ShareDB) or a CRDT.

The architectural consequence of OT: transformation needs a total order of operations, so you need a central server per document that sequences ops. Clients send operations tagged with the last server revision they saw; the server transforms the incoming op against everything that landed since that revision, appends it to the log, and broadcasts.

// Server-side receive loop (simplified ShareDB model)
function receive(op: Op, clientRev: number) {
  const concurrent = log.slice(clientRev);      // ops client hasn't seen
  for (const c of concurrent) op = transform(op, c);
  log.push(op);                                 // rev = log.length
  broadcast(op, log.length);
}

Approach 2: CRDTs

A Conflict-free Replicated Data Type makes convergence a property of the data structure itself. For text, each character gets a globally unique, totally ordered ID (e.g., (counter, siteId) in RGA, or a fractional position list in Yjs). Insert means "place this character after ID X" — a statement that is unambiguous no matter when it arrives. Deletes mark tombstones.

Because operations commute, no central sequencer is required. Replicas can sync peer-to-peer, offline edits merge cleanly, and the server can be a dumb relay. The costs: metadata overhead per character (modern libraries like Yjs compress this aggressively), tombstone accumulation, and interleaving anomalies when two users type in the same spot.

OT vs CRDT: the decision table

DimensionOTCRDT
ServerRequired, sequences opsOptional relay
Offline / P2PPainfulNatural
Rich text correctnessMature (Docs, Office)Newer, now solid (Yjs, Automerge)
Per-char overheadNoneMetadata + tombstones
Implementation riskTransform edge casesLibrary does the hard part

In an interview, say this out loud: OT centralizes the complexity in a transform function and demands a sequencing server; CRDTs move the complexity into the data structure and free the topology. Then pick one and go deep.

The rest of the system

Transport: WebSocket per client, sticky-routed to the server instance that owns the document session. Ownership via a directory service (document ID → session server) in Redis or ZooKeeper; if the owner dies, a new instance loads state and clients reconnect.

Persistence: append ops to a log (the source of truth), snapshot the materialized document every N ops so loading is snapshot + tail replay, not a million-op replay. Version history falls out of the op log for free.

Presence (cursors, selections): ephemeral state, broadcast on the same socket, never persisted. Cursor positions are transformed against incoming ops too — that is why remote cursors do not drift when you type above them.

Scale: co-editing sessions shard naturally by document ID. A single document rarely exceeds ~100 live editors; beyond that (a viral public doc), degrade gracefully to view-only followers reading from a fan-out of the op stream.

What interviewers probe

Why can't you just use last-write-wins? (You lose keystrokes — unacceptable.) Why does OT need a central server? (Transforms need a total order.) How does undo work? (Invert your own ops and transform the inverse against everything since — nastier than it sounds.) How do you handle a 10MB doc joining cold? (Snapshot + delta, lazy-load off-screen sections.)

The signal is not naming OT or CRDT — it is showing you understand why concurrent edits diverge and where each approach pays its complexity bill.

Keep reading

Newsletter

New posts, straight to your inbox

One email per post. No spam, no tracking pixels, unsubscribe anytime.

Comments

  • No comments yet. Be the first.