Design a collaborative editor (Google Docs)

Systems design · Social and messaging · Jul 2026

A collaborative editor lets many people type into one document at the same time and see each other's changes and cursors appear within a fraction of a second. The difficulty is not the text box. It is that everyone is editing the same shared state at once, so two people who type in the same spot at the same moment must both keep their change and end up looking at an identical document, no matter which edit reached the server first. Get that wrong and you produce the worst bug in the category, which is a keystroke that silently disappears.

The one-line version. Never send the whole document. Send small operations like insert(5, ", world") and delete(6). A central server puts them in one canonical order and transforms concurrent operations so their positions stay correct, which is what keeps every editor converging on the same text. That transform step, Operational Transformation, is the center of the design, and the cap of 100 editors per document is what lets it all fit on a single server.

Scope and requirements

Functionally, a user creates a document, many users edit it at the same time, everyone sees the others' edits live, and everyone sees the others' cursors and whether they are present. Rich text, permissions, and version history are real features that can be parked once they are named, so this stays a plain-text editor to keep the focus on multi-writer consistency.

Non-functionally, four constraints shape the design. Every editor must converge on the same document, and this only needs to be eventual, since two people do not have to see edits in the same physical order as long as they end up with identical text. Updates must land in under 100 milliseconds so typing feels shared rather than laggy. The system must hold millions of concurrent users across billions of documents. And there is one deliberately load-bearing limit, at most 100 concurrent editors per document, which is what makes it possible to keep all of a document's connections and operations in the memory of one server. Real Google Docs does the same, dropping extra people to read-only past a threshold. Durability rounds it out, since edits have to survive a server restart.

Sizing the problem

The 100-editor cap is the number the whole design leans on. It means a document is never a throughput problem, so one Document Service server can own a document outright, hold its operations in memory, and order edits without coordinating with anyone. The pressure lives elsewhere. Shipping the full document on every keystroke would move the whole document on every key press, which is the first reason that approach dies. Storage is the other one, since billions of documents at roughly 50 KB each is about 50 TB, and a busy document accumulates operations without end, so something has to collapse that log. A single unscaled server handles only a few thousand users total, which is what forces the connection-sharding work later.

Why sending the whole document loses edits

The first design has each editor send the entire document to the server on every change, with the server keeping the latest copy. It fails immediately under concurrency. If User A and User B both edit at the same moment, each sends a full snapshot built from what they saw a moment ago, and whichever snapshot arrives last overwrites the other completely. A's paragraph vanishes because B's snapshot never contained it. This is last-write-wins on the whole document, and it silently destroys concurrent edits, on top of the bandwidth problem. The fix is to stop sending documents and start sending operations, small deltas like insert this text at this position or delete this many characters here.

Core entities and the data model

Four nouns carry the design. An Editor is a user in a document. A Document is the text being edited. An Edit, or operation, is a single change such as insert(pos, text) or delete(pos, count). A Cursor is an editor's position and, by extension, their presence.

The stores split by how they are used. Document metadata is small and stable, so it lives in Postgres, holding the document id, title, and a documentVersionId that points at the current version. The operations are large and append-heavy, so they live in Cassandra, partitioned by document id and ordered by a server-assigned timestamp. The important idea is that the document is not stored as a blob. It is stored as the sequence of operations that built it, and the text is reconstructed by replaying them. Opening a document reads its version pointer from Postgres, reads that version's operations from Cassandra, and replays them.

metadata (Postgres)          operations (Cassandra, partition = docId, order = ts)
  docId                        docId | version | ts        | op
  title                        d1    | 41      | ...       | insert(0, "H")
  documentVersionId  ---------> d1    | 42      | ...       | insert(1, "i")
                               d1    | 43      | ...       | delete(0, 1)

The connection model

Editing is a two-way, high-frequency stream. The client is constantly pushing keystrokes and cursor moves up, and the server is constantly pushing other people's edits down, so the connection is a WebSocket, one per open document. Server-sent events are one-directional and cannot carry the client's own edits back up, which rules them out here even though they fit a read-only feed. Polling cannot meet the 100-millisecond target without wasting a request on every quiet interval. A user with three documents open holds three sockets, which is fine because those are usually separate tabs anyway.

The high-level design

Creating a document is ordinary web-service work. The client calls an API gateway, a CRUD service writes the id and title to Postgres, and returns the id. Editing is where the design earns its keep. A client sends each change as an operation to the Document Service, the stateful server that owns the document in memory. That server transforms the operation against the operations it has already ordered, appends it to Cassandra with a server-set timestamp that fixes its place in the canonical order, and only acknowledges the client after the durable write so a lost operation is simply retried. It then broadcasts the operation to every other editor over their sockets. On connect, the server replays all of the document's prior operations to the newcomer so everyone starts from the same baseline.

Editor A Editor B Document Serviceowns the doc in memoryOT, sequences, broadcasts Operations logCassandra, by docId Doc metadataPostgres, version ptr edits + cursors WebSocket append ops version ptr

Every editor of a document holds a WebSocket to the one server that owns it. That server orders operations, appends them to the Cassandra log, tracks the version pointer in Postgres, and fans edits back out. Databases are drawn as cylinders.

Operational Transformation, the heart of it

An operation carries an index, and that index is only meaningful against the exact document state it was created on. A delete(6) means delete the character at position 6 as of the moment the user pressed backspace. If someone else's insert lands first and shifts everything to the right, position 6 now points at a different character, and applying the delete as written removes the wrong one. Operational Transformation fixes this by rewriting an incoming operation against the operations already ordered ahead of it, so it still does what the user meant.

The document is "Hello!", and the exclamation mark is its sixth character. User A sends insert(5, ", world"), which drops seven characters in just after Hello, and User B sends delete(6) to remove the mark. The server orders A first, so those seven characters now sit ahead of the mark, which has slid from the sixth character to the thirteenth. B's delete is transformed from delete(6) into delete(13), so the exclamation mark is still what gets removed, and both editors end up with "Hello, world".

"Hello!"base A: insert(5, ", world")7 characters B: delete(6)the "!" order: A, then Bserver sequences transformdelete(6) → delete(13) "Hello,world" A added 7 characters before position 6, so 6 + 7 = 13

The delete is re-expressed against the insert that was ordered ahead of it, so the intended character is still the one removed. Green is the converged result both editors see.

The central server is the single source of the canonical order, so there is one ground truth to transform against. And each client transforms locally too. A client applies its own edits optimistically the instant they are typed, so the editor feels immediate, and holds them unacknowledged. When a remote operation arrives, the client transforms it against its own unacknowledged operations before applying it. The physical order of edits therefore differs per screen, yet everyone converges on the same text, which is the guarantee OT gives.

# an operation is a small delta, never the whole document
#   ("insert", pos, text)   e.g. ("insert", 5, ", world")
#   ("delete", pos, count)  e.g. ("delete", 6, 1)

def transform_against_insert(op, ins):
    """Re-express op so it still means what the user intended, given that
    `ins` (an insert) was ordered before it."""
    _, ins_pos, ins_text = ins
    kind, pos, arg = op
    if pos >= ins_pos:                  # the insert landed at or before us
        pos += len(ins_text)            # ... so shift right by its length
    return (kind, pos, arg)

# "Hello!", A = insert(5, ", world") sequenced first, B = delete(6)
transform_against_insert(("delete", 6, 1), ("insert", 5, ", world"))
#   -> ("delete", 13, 1)   the "!" is still the character removed
# client -> server: each edit is an operation tagged with the version it was made against
ws.send({"type": "insert", "pos": 5, "text": ", world", "baseVersion": 41})

# the Document Service that owns this doc
def on_op(op, base_version):
    for prior in ops_since(base_version):      # transform against newer ordered ops
        op = transform(op, prior)
    version = append_to_cassandra(doc_id, op)  # server timestamp fixes the order
    ack(op, version)                           # only ACK after the durable write
    broadcast_to_others(doc_id, op, version)   # local fan-out, all editors are here

OT versus CRDT, and what will not work

The other way to make concurrent edits converge is a conflict-free replicated data type. A CRDT gives every character a unique, immutable position identifier that never changes once assigned. The identifiers are densely orderable, so a character inserted between positions 4 and 5 gets something like 4.3, built from a random value plus a per-site tiebreaker so that two people inserting in the same spot still get distinct, orderable ids. A new character slots between two existing ones without touching either, and because merges commute and need no referee, CRDTs can run peer to peer and offline. The cost is heavier metadata on every character and tombstones for deletes. Libraries like Yjs and Automerge implement CRDTs for exactly this kind of text.

The trade is clean. OT is light on memory and fits text well, but it needs a central server to order operations. CRDT needs no central authority but carries more metadata on every character, and OT is in turn the one that is notoriously hard to prove correct once the central server is removed, which is much of why CRDTs exist. Google Docs uses OT, because it already runs a central stateful server for durability and broadcast, and because the 100-editor cap and plain-text focus erase the advantages a CRDT would bring. You would reach for a CRDT when you have far more collaborators, a peer-to-peer target over WebRTC, or serious offline editing.

The approaches that definitely do not work. Full-document snapshots lose every concurrent edit to last-write-wins and move the whole document on every key press. Raw index edits with no transformation corrupt intent, since a delete at position 6 removes the wrong character once someone else's insert has shifted the text. Locking the whole document so one person edits at a time removes the entire feature. And Kafka on the realtime write path adds latency for no durability gain, because a database commit is already a commit, and buffering edits through a long outage hides conflicts and corrupts the document rather than saving it.

Keeping storage bounded with compaction

Storing the operation log and replaying it is clean until the log gets long. Billions of documents push storage toward tens of terabytes, a long-lived document accumulates millions of operations that are slow to transfer and replay on join, and active documents sit in a server's memory. Compaction fixes all three by collapsing many operations into one, so a whole document becomes a single insert(0, contents). Compaction writes a new version and then flips the documentVersionId pointer in Postgres with a compare-and-swap.

The timing is the subtle part. Compaction rewrites the operations that follow, so it must not corrupt a document that is being edited. Cassandra offers only row-level transactions, so the design leans on that version pointer as the atomic swap, and if new edits arrive mid-compaction the compaction is aborted. The easy case, and the one to lead with, is offline compaction that runs when the last editor disconnects, because at that instant the Document Service holds every operation in memory, owns the document exclusively, and has no one to coordinate with. A common and reasonable extension writes a full snapshot to object storage and keeps only the tail of recent operations in Cassandra, which is the same shape as a database checkpoint plus its write-ahead log.

Sharding connections by document

One server cannot hold millions of sockets, and it is a single point of failure. The constraint that decides how to scale is that OT needs all of a document's operations and all of its editors on one server, so the shard key is the document. The Document Service runs as a ring of servers placed by consistent hashing on the document id, with ZooKeeper holding the ring. A client opens a plain HTTP connection to any server with the document id, that server checks the ring, and if it is not the owner it redirects the client to the one that is. The owner upgrades the connection to a WebSocket and loads the document's operations from Cassandra if they are not already in memory. Every editor of that document is now on one server, so a broadcast is a local fan-out.

Editoropens docId ZooKeeperholds the ring config DS 1hash 0-90 DS 2 (owner)hash 90-180 DS 3hash 180-270 DS 4hash 270-360 any server redirect to owner

Servers sit on a consistent hash ring keyed by document id, so adding or removing one reshuffles only a small slice of documents. A client is redirected to the owner of its document, which then holds the WebSocket and the in-memory operations.

Consistent hashing keeps a membership change cheap, since only a fraction of documents move, but the cost is that a ring change has to move state, displacing editors so they reconnect to the new owner and migrating the document's operations. This is also why chat-style Redis pub/sub fan-out, which works when a channel's users can sit on different stateless servers, does not fit here. OT needs every operation for a document on one stateful server, so a server publishing only to itself buys nothing, and because pub/sub is fire-and-forget, a client that drops for a moment simply misses the operations it needed, which the one owning server would otherwise have replayed.

Questions and answers

The core ideas as questions with the answers given outright. Each wrong multiple-choice option is marked with why it is wrong, and the ordering ones show the correct sequence.

1In the naive design, every editor sends the full document as a snapshot on each edit. Why does this cause concurrent edits to be lost?
  • When two snapshots arrive close together, the one that lands last overwrites the other, so the earlier user's change is gone
  • Snapshots are too big to fit in a single WebSocket frame, so the server truncates them and drops edits. Size is not the failure here, and the server does not truncate. Even a tiny snapshot still overwrites.
  • The server cannot hold two snapshots in memory at once, so it deletes the older one to save space. The server can hold both. The problem is that a snapshot replaces the whole document.
  • Snapshots leave out cursor and presence data, so the server rejects them and the edit never applies. Snapshots are not rejected, and presence is tracked separately from the text.
Why: It is tempting to blame the size of a snapshot, but bandwidth is not the failure here. A snapshot is a full replacement of the document, so whichever one writes last wins and silently erases the other person's work. Even tiny, fast snapshots would still clobber each other. The fix is to send small operations like insert and delete so the server can order them and keep both changes.
2Put the sequence of events in order for how two concurrent edits get lost when editors send full-document snapshots.
  1. Two users open the same document at the same time
  2. Each user makes a different change in their own editor
  3. Each editor sends the entire document back as a snapshot
  4. The two snapshots reach the server one after the other
  5. The snapshot that arrives last overwrites the other, so one user's change is lost
Why: You might assume the server could just compare the two snapshots and merge them, but a snapshot carries no record of what actually changed, only the final text. With nothing to merge against, the server keeps whichever wrote last. Sending operations instead of snapshots is what gives the server the per-edit information it needs to order both changes and keep them.
3In this collaborative editor design, which statement best describes an EDIT (operation)?
  • A single change such as insert(position, text) or delete(position, count)
  • The full text content that the editor manages. That is the Document, the text being managed, not an Edit.
  • A user's caret or selection position along with their online presence. That is a Cursor, a position and presence, not an Edit.
  • A snapshot of the whole document that is sent on every change. That is the naive snapshot approach that loses concurrent edits, not an operation.
Why: It is tempting to picture an edit as shipping the new document state, but that is the naive snapshot approach that loses concurrent changes under last write wins. An edit is a small operation like insert or delete, which is exactly why the server can order and transform it without clobbering another user's work.
4Because moving your caret is a kind of action, a cursor update is treated as an edit operation applied to the document.
  • True
  • False
Why: Moving the caret feels like doing something, but it does not change the text content, so it is not an edit. A cursor is presence data (position plus online state) that is high frequency but disposable, while an edit is a durable operation like insert or delete that must be ordered and never lost.
5In OT, what is the central server's job when two edits to the same document arrive at nearly the same time?
  • It keeps whichever full-document snapshot arrived last and drops the other one.. That is the snapshot path that silently loses edits, not what OT does.
  • It picks one order for the operations that all clients follow, and transforms the later ones so their indices still point at the right characters.
  • It locks the document so only the first editor's change lands and the other user has to retype.. Locking kills concurrent editing. OT keeps both edits and reorders them.
  • It gives every character a permanent id up front so edits never need transforming at all.. Assigning permanent ids up front is the CRDT idea. OT transforms indices instead.
Why: It is tempting to picture the server just merging the two edits or letting the last one win, but that is the snapshot path that quietly loses changes. OT keeps both operations and rewrites the index of the later one, which is exactly why no edit gets dropped.
6The document reads "Hello!". User A sends INSERT(5, ", world") and User B sends DELETE(6) to remove the "!". A's insert reaches the server first. Put the steps in the order they happen as B's delete propagates to everyone.
  1. User A sends INSERT(5, ", world") and User B sends DELETE(6) to the central server.
  2. The server picks the total order and applies A's insert first, turning "Hello!" into "Hello, world!".
  3. Because 7 characters now sit before position 6, the server rewrites B's DELETE(6) into DELETE(13).
  4. The server applies DELETE(13) to drop the "!" and broadcasts the ordered operations to every client.
Why: A natural but wrong assumption is that the transform comes first and the ordering second, or that each client patches its own indices. The server has to fix the order first, because only once it knows A's 7 characters landed before position 6 can it turn DELETE(6) into DELETE(13).
7In a CRDT-based editor, a user types a character between two existing characters. Why do the two neighbors' position identifiers stay exactly the same?
  • The identifiers are densely orderable and infinitely subdividable, so a new identifier can always be generated that sorts between the two neighbors without touching either one
  • A central server renumbers every identifier after the insertion point and pushes the new numbering out to all clients. CRDTs have no central renumbering. The identifiers are immutable once assigned.
  • The editor shifts the index of every character to the right of the insertion by one, the same way OT does. Shifting every index to the right is OT's model, not CRDT. CRDT identifiers never move.
  • The existing identifiers are re-spaced evenly on each insert so a fresh gap opens up for the new character. Existing identifiers are not re-spaced. A fresh identifier is minted strictly between the two neighbors.
Why: It is tempting to picture this like inserting into an array, where everything after the gap has to slide over to make room. CRDT identifiers are not array indices. They live in a continuous, infinitely subdividable space like the real numbers, so there is always room to mint a new id strictly between any two existing ones. Nothing slides and nothing gets renumbered, which is exactly what lets the ids stay immutable.
8Order the steps a CRDT replica follows to insert a character between two existing characters and share it with everyone else.
  1. Find the identifiers of the left and right neighbors that bound the spot where the new character goes
  2. Generate a new identifier that sorts strictly between those two neighbors, using the dense subdivision of the identifier space
  3. Attach that new immutable identifier to the character while leaving every existing identifier unchanged
  4. Send the character and its identifier to the other replicas
  5. Each replica places the character in identifier order and reaches the same document, with no central server deciding the order
Why: A natural but wrong assumption is that a server hands out the identifier or decides the final order first. The new id cannot be minted until you know which two neighbors it must fall between, so locating the neighbors has to come first. And because every replica sorts by the same immutable identifiers, the merge lands on the same result on its own. No central sequencer is involved, which is the property OT does not have.
9Which statement correctly describes the tradeoff between Operational Transformation (OT) and CRDTs for a collaborative editor?
  • OT works peer to peer with no central server, while CRDTs need a central server to merge everyone's changes.. This is backwards. OT is the one that needs a central server, and CRDTs are the ones that can run without one.
  • CRDTs use lighter metadata than OT because each character only has to store its current index.. CRDTs carry more per-character metadata, not less, and they do not use array indices at all.
  • OT keeps each operation lightweight but relies on a central server to set the order, while CRDTs drop the central authority and pay for it with heavier per-character metadata.
  • OT and CRDT are the same technique under two names, so the choice between them makes no real difference.. They are genuinely different approaches with real trade-offs, so the choice matters.
Why: The tempting read is that 'no central server' makes CRDTs the lighter, simpler option. It is actually the reverse. Dropping the central sequencer is exactly what forces every character to carry its own unique, immutable identifier, so a CRDT trades server dependence for more metadata and harder code. Google Docs went with OT because a central server ordering the operations lets each edit stay small.
10Locking the whole document so only one person can edit at a time is a workable way to build a real-time collaborative editor like Google Docs.
  • True
  • False
Why: Whole-document locking feels safe because it prevents conflicts by construction, but that is the wrong goal. The point of a collaborative editor is many people editing at once and seeing each other within a fraction of a second, and a lock serializes everyone down to a single editor, which kills exactly that. The real job is to merge concurrent edits, not to forbid concurrency, which is why Google Docs uses OT instead.
11A Document Service instance holds an active document's operations in memory. Why does offline compaction run at the moment the last client disconnects?
  • At that moment the service has the full set of operations, exclusive ownership of the document, and no active editors to coordinate with, so it can safely collapse operations into a snapshot.
  • Compaction needs to lock the whole document, and locking is only safe once nobody is editing it.. It is not about locking. The owner already holds every operation in memory.
  • Ownership passes to a backup Document Service instance on disconnect, and that instance rebuilds the document from a fresh snapshot.. Ownership does not hand off to a backup on disconnect. The same owner runs the compaction.
  • Connected clients block snapshot writes over their WebSocket, so the write has to wait until every socket closes.. Clients do not block writes over the socket. The trigger is simply that no editors remain to coordinate with.
Why: You might picture compaction as a background timer that fires on a schedule no matter who is connected. The problem is that rewriting history while editors are still streaming operations you have not sequenced yet risks dropping or reordering their edits. The last client leaving is the signal that the in-memory operation set is complete and there is nobody left to coordinate with, which is what makes the collapse safe.
12True or False: while a single document is being actively edited, several Document Service instances can each hold it in memory at once to share the editing load.
  • True
  • False
Why: It feels natural to scale a hot document by spreading it across many instances, the same way you would scale stateless web servers. But one instance takes exclusive ownership of a document precisely so there is a single authoritative place to order operations. Scaling comes from sharding different documents across instances, not from copying one document onto several.
13Put the steps for loading an active document from storage into the correct order. Storage is an append-only operation log with periodic snapshots.
  1. Find the most recent snapshot saved for the document.
  2. Load that snapshot into memory as the document's starting state.
  3. Read the operations from the append-only log that were written after that snapshot.
  4. Replay those operations in order to rebuild the document's current state.
Why: It is tempting to think loading means replaying the whole operation log from the very first edit. That gets slower and slower as a document ages. Snapshots exist so you can start from a recent saved state and only replay the operations recorded since, which is also why compaction folds old operations into a fresh snapshot.
14You are building the live editing transport for a collaborative document editor. Why hold a persistent WebSocket per client instead of using SSE or polling?
  • WebSocket compresses each edit, so it moves less data than SSE or polling, and that bandwidth saving is the reason it was chosen.. Bandwidth is not the reason. The reason is constant two-way, keystroke-rate traffic.
  • SSE and polling cannot send any data from the server to the client, so only WebSocket can show a user the edits other people make.. SSE can push and polling can fetch server data. What they cannot do well is carry the client's constant upstream edits.
  • WebSocket is bidirectional, so one persistent connection carries the client's own edits upstream and other people's edits back down. SSE only flows server to client so it cannot carry edits upstream, and polling is too slow and wasteful at keystroke rate.
  • WebSocket makes operations arrive in the correct total order, which SSE and polling cannot guarantee.. The total order comes from the server sequencing operations, not from the transport.
Why: The easy mistake is assuming an editor only needs to receive other people's changes, which SSE already does well. But each editor also has to push its own keystrokes up to the server, and SSE runs one direction only, server to client, so it cannot carry those edits upstream. Ordering is not the transport's job either, the central server sets the total order, so that is not the deciding factor. WebSocket wins because it moves both directions over one low latency connection.

References

  1. Ellis and Gibbs, Concurrency Control in Groupware Systems (1989), the original Operational Transformation paper.
  2. Shapiro et al., Conflict-free Replicated Data Types (2011).
  3. Google Drive Engineering, What's different about the new Google Docs (2010), on managing edits server-side.
  4. Kleppmann, Designing Data-Intensive Applications (2017), on logs, snapshots, and consistency.