This is the first part of a 2-part series. In this part we'll understand how a collaborative editor like Google Docs works: architecture, resolving concurrent edits (OT vs CRDT), storage strategy, and real-time sync. Part 2 covers offline editing, permissions (Zanzibar), fault tolerance, and scaling.
Imagine you and 4 of your friends are all writing in the same Google Doc together. You're typing a paragraph, one person is editing the heading above, another is adding a comment, and someone is offline — no internet but still typing away. A little while later everyone's screen shows the exact same document. No conflicts, no lost text, latency within 100-200ms.
You have to first appreciate what a huge engineering feat this is. In this post we'll go step-by-step through how to design a system like Google Docs. Where to make which trade-off, which algorithm is chosen and why, how scale is handled — we'll discuss it all.
What's the actual problem?
The core challenge in one line: "Multiple people are typing, deleting, and formatting at the same time — every change has to show up on everyone's screen almost instantly, and everyone has to end up with the same document."
A lot of subtle things are hidden inside this:
- Real-time sync — sub-200ms latency, otherwise collaboration won't feel "natural"
- Offline editing — work continues even if the internet drops, and merges back when it returns
- Global scale — millions of concurrent users, multiple regions
- Version history — tracking who wrote what and when
- Permissions — who can view, who can edit, who can only comment
These are hard individually. Together they're harder.
Let's clarify the requirements
The first job in system design is nailing down the requirements. If you rush into drawing architecture, you'll have to refactor later.
Functional Requirements
- Real-time multi-user editing — several people can edit at once
- Version history — every change must be recorded (who, when)
- Document sharing — owner, editor, commenter, viewer — four roles
- Offline editing — queue changes locally, sync when back online
- Comments & suggestions — inline comments, suggestion mode
Non-Functional Requirements
- Low latency — 100-200ms target
- High availability — 99.9% uptime
- Fault tolerance — must handle server crashes, network partitions
- Durability — no text is ever lost, multi-region replication
Interview tip — Why do you need scale numbers?
If you start designing with just a feature list, the interviewer will catch you right away. That's because a system's architecture depends entirely on how big the scale is. For 1000 users, one MySQL and one server is enough. For 100 million users you'll need sharding, multi-region replication, a caching layer — all of it.
So while clarifying requirements you have to put down some assumption numbers. For example:
- 10M DAU — DAU means Daily Active Users, how many users are active in the system per day
- 100 edits/min per active user — how many edit operations each user generates per minute on average (treat one keystroke as one edit)
- Document size 100KB — how big a document is on average
From these three numbers you can derive — what the peak QPS is (Queries Per Second — how many requests come in per second at peak), how much storage you'll need, how much bandwidth, how many servers. Without these, architecture decisions (like "Bigtable or a single Postgres") are meaningless.
Made-up numbers are fine too — you don't know Google's internal data anyway. The interviewer wants you to reason forward from reasonable assumptions. Starting with "let's say 10M DAU" is no problem, as long as the later calculations stay consistent with that number.
High-level Architecture
Take a quick look at what the whole system looks like:
┌─────────────┐ WebSocket ┌──────────────────┐
│ Client │ ←──────────────→ │ Load Balancer │
│ (Browser) │ └────────┬─────────┘
└─────────────┘ │
↓
┌───────────────────────┐
│ Collaboration Servers │
│ (OT/CRDT engine) │
└─────┬──────────┬──────┘
│ │
┌─────↓────┐ ┌───↓─────┐
│ Pub/Sub │ │ Storage │
│ (Kafka) │ │ Layer │
└──────────┘ └─────────┘
│
┌─────────────────┼──────────────────┐
↓ ↓ ↓
┌─────────┐ ┌──────────┐ ┌──────────┐
│Bigtable │ │ Spanner │ │ Colossus │
│(ops log)│ │(metadata)│ │ (blobs) │
└─────────┘ └──────────┘ └──────────┘
Four layers:
-
Client — browser or mobile app. Captures user input, holds local state, does optimistic updates (shows changes in the UI before the server even responds).
-
Collaboration Server — the main brain. Takes operations from all clients, transforms them, and broadcasts them.
A bit more detail — this server's job actually has three stages:
-
Receive: Operations come from the client over WebSocket. Each operation is a small JSON, like
{type: "insert", position: 42, text: "hello", author: "user-A", op_id: "abc-123"}. The server first validates — does the user have edit permission? Is the operation's format correct? -
Transform (resolve conflict): This is the hardest part. When two users send operations at nearly the same time, the server runs an OT (Operational Transformation) or CRDT (Conflict-Free Replicated Data Type) algorithm to figure out — which operation comes first, which comes later, and how the position of the later operation changes relative to the effect of the earlier one. Example — A inserts at position 5, B deletes at position 3; then A's insert position may need to be adjusted to 4. There's a deep dive on this logic in the next section.
-
Persist & Broadcast: The transformed operation is sent to two places — (1) appended to the storage layer (Bigtable's operation log, so there's version history and recovery on a crash), (2) broadcast to subscribed clients via Pub/Sub (everyone who has that document open gets the change on their screen in real time).
Besides these, the server has a few more important responsibilities:
-
Leader election — remember, the system isn't running one collab server, it's running thousands of servers (at global scale). So here's the question — if 5 users edit the same document and their requests go to 5 different servers, what happens? Each server applies operations independently, so you end up with 5 different document states in 5 places — conflicting state, none of them matching. To prevent this, one specific server is made the "leader" for each document (assigned via consistent hashing —
hash(doc_id) → server X). All operations for that document go only to that leader server, the leader processes them sequentially, and broadcasts them to everyone in the same order. If the leader crashes, another server becomes the new leader (via the Raft/Paxos protocol). -
Presence tracking — who currently has the document open, where each person's cursor is, who has selected which paragraph — tracking this ephemeral state. This isn't persisted to storage; it lives in the server's memory and is cleared when a user disconnects.
-
Session management — when a WebSocket connection drops, it's not right to expire the session immediately (the user might reconnect 5 seconds later). So some state is held for a while — the pending operation buffer, the last known cursor position, etc. How long to hold it is a tunable trade-off (memory vs UX).
It's kept stateless deliberately — meaning all state lives in Bigtable/Spanner, and the server's memory holds only a transient cache. This way if any server crashes, another server can take over responsibility for that document.
-
-
Sync Layer — WebSocket connections, fan-out via Pub/Sub (Kafka/Google Pub/Sub). (There's a flow diagram and detail below in the "Real-time Sync: WebSocket and Pub/Sub" section.)
-
Storage — three kinds of data, three kinds of databases:
-
Bigtable — for keeping the operation log. Every edit operation is appended here. Bigtable is a wide-column NoSQL, extremely fast on write-heavy workloads and horizontally scalable — so it can handle millions of operations/sec. (There's detail below in the "Storage Layer: Operation Log + Snapshots" section, with JSON schema and snapshot strategy.)
-
Spanner — for metadata and permissions. Things like the document's title, owner, created_at, which user has which role (editor/viewer/commenter) — all of this. This data is critical and needs strong consistency (a wrong permission check is a security breach), so Spanner — Google's globally-distributed relational DB that guarantees ACID transactions across multiple regions.
-
Colossus — for file attachments, embedded images, video. This is Google's internal distributed file system (the successor to GFS). When an image is embedded inside a document, the image is actually stored in Colossus, and the document only holds a reference (URL/blob ID). Keeping small structured data separate from large binaries — a classic pattern.
-
The real challenge: Concurrent Edits
Say the document contains: "Hello"
- User A: inserts
" World"at position 5 →"Hello World" - User B: at the same time deletes the
Hat position 0 →"ello"
Both operations reach the server at nearly the same time. Now what happens?
- Applying A's operation first:
"Hello"→"Hello World"→ then B's delete →"ello World" - Applying B's operation first:
"Hello"→"ello"→ then A's insert at position 5 → but now there's nothing at position 5! The document's length is only 4!
This is the convergence problem. Everyone has to end up with the same result — whatever the order.
There are two classic approaches to solving this problem: Operational Transformation (OT) and Conflict-Free Replicated Data Types (CRDT).
Approach 1: Operational Transformation (OT)
The core idea of OT — transform an operation relative to other concurrent operations.
In the example above:
- When B's delete (position 0) was concurrent with A's insert (position 5), the server transforms B's operation for A's state. Since B's delete is before A's insert position, it transforms A's insert position 5 down to position 4.
- Final result for everyone:
"ello World"(length 9).
OT's basic transformation function
A small example — let's transform two insert operations:
// Op1: insert text t1 at position p1
// Op2: insert text t2 at position p2 (concurrent)
function transform(op1, op2) {
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position < op2.position) {
// op1 comes first, op2's position must shift
return { ...op2, position: op2.position + op1.text.length };
} else if (op1.position > op2.position) {
// op2 comes first, op2's position is unchanged
return op2;
} else {
// same position — need a tiebreaker (compare client ID)
return op1.clientId < op2.clientId
? { ...op2, position: op2.position + op1.text.length }
: op2;
}
}
// insert vs delete, delete vs delete — each has its own rule
}
You have to write a separate transformation function for each operation pair. With 4 operation types, that's 16 combinations. Complexity grows fast.
Characteristics of OT
| Aspect | Description |
|---|---|
| Pros | Battle-tested (used by Google Docs, Etherpad), bandwidth efficient |
| Cons | Transformation functions are hard to write, N² functions for each new operation type |
| Centralized? | Usually yes — a central server decides the operation order |
Approach 2: CRDT (Conflict-Free Replicated Data Types)
CRDT's philosophy is different — design the data structure so that concurrent modifications can never conflict.
The most common CRDT for text is RGA (Replicated Growable Array) or Logoot/Treedoc. Each character has a unique ID — usually a (replicaId, counter) tuple. Position isn't numeric; instead a dense fractional ordering is used — like a rational number or a list of integers.
Example: Logoot-style position
First understand two terms:
-
Replica — an independent copy of the document. In CRDT there's no central server; every client (browser, mobile app, offline laptop — wherever there's a copy of the document) is a replica. User A's browser = replica A, User B's browser = replica B. Each replica edits its own copy independently, then syncs with the other replicas later.
-
replicaId — each replica's unique identifier. Usually a UUID, or a
user_id + device_idcombination, or a random string generated at session start. The purpose — so that every running replica in the system can be told apart from every other. No two replicas will ever have the same replicaId (UUID collision is practically impossible).
Now the core trick — in CRDT each character's position is a tuple: (numeric_part, replicaId). The numeric_part sets the main ordering; if two people's numeric_part happens to be the same (collision), the replicaId breaks the tie. That second slot is the trick — even with a collision, the ordering stays deterministic.
Document: "Hello" (initial state, no replica has inserted anything yet)
character 'H' position: (0.1, _)
character 'e' position: (0.2, _)
character 'l' position: (0.3, _)
character 'l' position: (0.4, _)
character 'o' position: (0.5, _)
Now A and B (two different replicas) both want to insert something between 'H' and 'e' at the same time:
A inserts 'X' → calculates position: (0.15, A)
// why numeric_part 0.15? because it's any number between 0.1 and 0.2
// A's replicaId goes in the tuple's second slot
B inserts 'Y' → calculates position: (0.15, B)
// B also picked the same 0.15 (both calculate independently, so collision is possible)
// but the replicaId differs — so the tuples differ: (0.15, A) ≠ (0.15, B)
When sorting, lexicographic compare:
(0.15, A) < (0.15, B) // numeric equal, so compare the second element — "A" < "B"
Final document: "H X Y e l l o"
↑ ↑
A before B — same order for everyone, deterministic.
The key insight: if position were just numeric, no one could decide who comes first on a collision. But with a (numeric, replicaId) tuple, the ordering stays unique even after a collision — each replica calculates independently and arrives at the same final order, without any central coordinator.
No transformation is needed — the operations are commutative, applying them in any order gives the same result.
Characteristics of CRDT
| Aspect | Description |
|---|---|
| Pros | Decentralized, peer-to-peer compatible, offline editing trivial |
| Cons | Metadata overhead (an ID per character), tombstones (a marker must be kept for each deleted character), range operations are complicated |
| Used by | Figma, Automerge, Yjs |
OT or CRDT — which one when?
Neither is universally better. Which one you use depends on your system's constraints, deployment model, and product requirements.
Use OT if —
- You have a central server architecture and will keep it (all operations go through one trusted server). OT's transformation logic works cleanest in a centralized environment.
- Bandwidth/storage is critical — you can't afford metadata overhead per character. OT's operations are lean:
{type, position, content}, no per-character ID. - You need a mature ecosystem — production-ready libraries, war stories, debugging tools. OT has decades of work behind it (Google Docs, Etherpad, ShareDB).
- The document type is complex but operation types are limited (like a rich text editor — insert, delete, format). Then writing transformation functions is manageable.
Use CRDT if —
- You need peer-to-peer / decentralized sync — no central server, or multiple servers syncing independently. CRDT's operations are commutative and converge without server coordination.
- Offline-first experience is a priority — a user might stay offline for days, and a fresh sync has to merge massive divergence. This is trivial in CRDT; painful in OT.
- You're building local-first software (Linear, a Notion-style sync engine, an Automerge-based app) — data stays primary on the user's device, the server is just a relay.
- Operation types will keep growing — adding a new feature means writing N² transformation functions for each one in OT; in CRDT, if the data structure is designed right, operations compose automatically.
- Mobile / unreliable network is the central use case — CRDT's reconciliation is simpler and retry-safe.
Quick comparison table
| Dimension | Better in OT | Better in CRDT |
|---|---|---|
| Architecture | Centralized | Decentralized / P2P |
| Bandwidth & storage | Less overhead | More (metadata, tombstones) |
| Offline divergence | Painful | Trivial |
| Adding a new operation type | Quadratic effort | Linear (mostly) |
| Implementation maturity | Battle-tested | Newer, fast-evolving |
| Conflict resolution | Server-side, explicit | Built into the data structure |
| Examples | Google Docs, Etherpad, ShareDB | Figma, Linear, Automerge, Yjs |
Real-world default
- Building a new product in 2026? Default to CRDT (start with Yjs or Automerge). The ecosystem is mature now, and offline + P2P scenarios are much easier in CRDT.
- Have an existing OT-based system? The migration effort is huge — usually you stay on OT, as long as it doesn't block you on scaling.
- There's a hybrid approach too — some systems use OT for document content and CRDT for metadata/cursor sync. One size doesn't fit all.
What does Google Docs use?
Google Docs uses OT. Historical reason — when they started (2006), CRDT was still at the academic research stage and wasn't production-ready. The centralized server model was perfect for OT. If they built it from scratch today they'd maybe pick CRDT — but the cost of migrating a legacy system is prohibitive.
Stating this trade-off explicitly in an interview is important. "I'd go with CRDT because peer-to-peer sync becomes possible, offline editing is more natural, and the library ecosystem (Yjs, Automerge) handles most of the heavy lifting" — give a reasoned answer like that and the interviewer is happy. Don't say a blanket "CRDT is better" or "OT is better" — context matters.
Storage Layer: Operation Log + Snapshots
How do we store the document? There are two naive approaches — both bad:
- Store only the final document — no version history, no information to resolve conflicts.
- Store each version separately — storage explodes.
The good approach: append-only operation log + periodic snapshots.
Operation Log
Every edit goes into the log as an operation:
{
"op_id": "uuid-...",
"doc_id": "doc-123",
"type": "insert",
"position": 42,
"content": "World",
"author": "user-A",
"timestamp": 1747800000000,
"parent_version": "v-789"
}
This is immutable — never modified, only appended. Easy to store in Bigtable or Cassandra.
Snapshot
Every N operations (or every so often), the whole document's state is saved — that's a snapshot. When loading a document:
- Load the most recent snapshot
- Apply the operations after it
- You've got the current document
v0 v1 v2 ... v999 [snapshot] v1000 v1001 v1002 ...
↑
replay from here up to latest
Tuning snapshot frequency is a trade-off: frequent snapshots = fast load, more storage. Fewer snapshots = slow load, less storage.
Version history
If the user wants, they can hit a "revert to a previous version" button — which means going back in the operation log and replaying up to that point. Keeping a record of every keystroke would grow storage, so Google Docs usually detects a "meaningful pause" and creates a version checkpoint.
A small example
Say Alice is writing a document. Every keystroke goes into the operation log, but a version checkpoint is created only on a "meaningful pause" (like not typing for 30 seconds, or a bigger structural change).
Time Operation Version checkpoint?
─────────────────────────────────────────────────────────────────
10:00:00 created the doc ✓ v1 (empty doc)
10:00:05 insert "Hello"
10:00:08 insert " world"
10:00:12 typing pause (30s) ✓ v2 ("Hello world")
10:02:30 Bob joined, insert " everyone"
10:02:45 Alice insert "!" at end
10:03:20 typing pause (30s) ✓ v3 ("Hello world everyone!")
10:05:00 Alice delete "everyone "
10:05:10 Alice insert "friends"
10:05:45 typing pause ✓ v4 ("Hello world friends!")
Now when Alice clicks "File → Version history" she'll see:
v4 — 10:05:45 "Hello world friends!" (current)
v3 — 10:03:20 "Hello world everyone!" [Bob joined here]
v2 — 10:00:12 "Hello world"
v1 — 10:00:00 (empty)
If Alice wants to revert to v2 — the system loads the v1 snapshot, replays operations up to v2 to get "Hello world", and the current document is replaced with that (it goes into the log as a new operation; the old history isn't wiped).
Notice — if you created a separate checkpoint for each character at the keystroke level, even this simple document would have 30+ versions. The "meaningful pause" brought it down to 4 — practical history, less storage cost.
Real-time Sync: WebSocket and Pub/Sub
In this section we'll understand — when Alice types a character on her keyboard, how it reaches Bob's screen within 100ms. You need to know two things: WebSocket (the real-time channel between client and server) and Pub/Sub (the system for getting a message to many clients at once from the server).
Why doesn't polling work first of all?
The most naive solution to real-time — the client asks the server "is there anything new?" every so often. This is called polling.
Client: "any new updates?" → Server: "no"
[100ms later]
Client: "any new updates?" → Server: "no"
[100ms later]
Client: "any new updates?" → Server: "yes, this op"
The problem: for a 100ms latency target, each client sends ~10 HTTP requests per second. 1 million concurrent users means 10M requests/sec at the server, 99% of which are empty responses. CPU, bandwidth all wasted. Plus every HTTP request has TCP handshake + TLS handshake overhead — latency goes up.
WebSocket — persistent bidirectional channel
WebSocket is a protocol that does a handshake over HTTP, then keeps the connection open — without closing it. Once connected, data can be pushed from both sides without sending any new request.
[Initial HTTP handshake: "Upgrade to WebSocket?"]
Client ←────────── persistent TCP connection ──────────→ Server
(stays open until disconnected)
Now at any time:
Server → Client: "apply this new op" (push, no polling)
Client → Server: "I generated an op"
Key advantages:
- Lower latency — handshake overhead is one-time, after that it's just sending data frames
- Server-initiated push is possible — unlike polling, you don't have to ask the client
- Bidirectional — messages go both ways on the same connection
When a client opens a document it establishes a WebSocket connection with the collab server. That connection stays live throughout the session.
What is Pub/Sub?
There's a problem with WebSocket — the connection is separate per client. Say 10,000 users have the same document open. Alice types a character. That operation now has to be sent to 9,999 people.
The naive approach — the collab server sends the message to each WebSocket one by one. 9,999 sends. Do this per operation and the server's CPU and network bandwidth are dead immediately.
The solution: the Pub/Sub (Publish-Subscribe) pattern.
Pub/Sub explained
Pub/Sub is a messaging architecture. Three components:
- Publisher — the one that creates and sends messages (here, the collab server)
- Subscriber — the one that wants to receive messages (here, each client's WebSocket handler)
- Broker / Topic — the system in the middle that routes messages (Kafka, Google Pub/Sub, Redis Pub/Sub, RabbitMQ — these tools)
It works like this:
- A Topic is created — like one topic per document:
doc-events:doc-123 - Whichever clients have opened this document subscribe to that topic — meaning they tell the broker "send me messages when they come to this topic"
- When the collab server generates an operation it publishes it to that topic — meaning it tells the broker "release this message on this topic"
- The broker looks at its subscriber list — and fans out the message to everyone who's subscribed
┌──────────────────────────┐
│ Topic: doc-events:doc-1 │
Collab Server ─publish→│ (Broker) │─fan-out→ Subscriber 1
│ │ ↘
└──────────────────────────┘ → Subscriber 2
↘
→ Subscriber N
Why this is good:
- The server's work is a commodity — the collab server only has to publish once, the broker handles the rest
- The broker is scalable — Kafka can scale horizontally and handle billions of messages/sec
- Decoupled — the publisher doesn't know how many subscribers there are, or who. When a new client joins it just subscribes
- Buffering — for a slow subscriber the broker can queue messages, so a slow client doesn't block the system for anyone else
Real-life analogy
Think of it like a YouTube subscription — every subscriber to a channel (topic) gets a notification for an uploaded video (message). The YouTuber doesn't have to send the video to each subscriber separately — YouTube's system handles that.
The whole flow together
Now let's see the full path of a typing event. Alice typed 'X', and Bob, Carol, Dave are looking at the same document.
Alice (Client) Collab Server Pub/Sub Broker Bob, Carol, Dave
│ │ │ │
│ │ │ (everyone subscribed │
│ │ │ "doc-events:doc-1") │
│ │ │ │
1. │─── WebSocket ───────→│ │ │
│ insert 'X' at 5 │ │ │
│ │ │ │
2. │ │ OT transform, │ │
│ │ persist to Bigtable │ │
│ │ │ │
3. │ │── publish ───────────→│ │
│ │ "doc-events:doc-1" │ │
│ │ {op: insert 'X' @ 5} │ │
│ │ │ │
4. │←─── ack ─────────────│ │ │
│ │ │ │
5. │ │ │── fan-out ───────────→│
│ │ │ same message │
│ │ │ to everyone's WebSocket
│ │ │ │
6. │ │ │ Apply op
│ │ │ UI re-render
The whole thing happens in ~50-100ms. On Bob's screen, Alice's typing shows up almost instantly.
Which tool for Pub/Sub?
| Tool | Strength | Best for |
|---|---|---|
| Kafka | High throughput, durable log | Operation log persist + replay |
| Google Pub/Sub | Fully managed, global | GCP-based products |
| Redis Pub/Sub | In-memory, ultra low latency | Ephemeral data (presence, cursor) |
| RabbitMQ | Flexible routing | When you need complex routing rules |
Google Docs probably uses an internal Pub/Sub system. In production many systems use two tools together — Kafka for the durable operation log, Redis for ephemeral presence/cursor sync.
Edge case: If a client's internet is flaky, some operations may be missed. So each operation has an
op_id— on reconnect the client can request the range of missing ops from the server ("I got up to op_id 500, send the ones after that"). The broker keeps recent operations buffered for this scenario.
At the end of Part 1
In this part we traced the whole way a functioning collaborative editor works:
- How to do requirements and scale estimation
- The high-level architecture of four layers (Client, Collab Server, Sync Layer, Storage)
- The core challenge of concurrent edits — the convergence problem
- Two classic solutions — OT (centralized, mature) and CRDT (decentralized, P2P-friendly)
- When to use which — a context-dependent decision
- Storage strategy — operation log + snapshots + version checkpoints
- Real-time sync mechanics — the WebSocket protocol + the Pub/Sub fan-out pattern
If you've read this far you can handle the core part of this question in an interview. But a production-grade Google Docs needs a lot more — offline editing, fine-grained permissions, fault tolerance, global scale. We'll discuss those in Part 2.

