cf.completefrontend
My progress
SYSTEM DESIGN · #98

Design a collaborative document editor like Google Docs

A rich text editor where many people type into the same document at once, each seeing their own keystrokes instantly and everyone else's within a fraction of a second. The hard parts are merging edits made at the same moment without losing anyone's work, keeping cursors, comments, and undo correct while the text moves, and catching up after hours offline.

The question

Design the web client for a collaborative document editor like Google Docs. People write documents with headings, lists, links, images, and tables. Several people can have the same document open and type into it at the same time, and everyone sees everyone else's changes and cursors live.

People can leave comments on a piece of text, undo their own changes, look at older versions, and keep writing on a train with no signal. Some documents are hundreds of pages long. Walk me through how the client works, and how it talks to the server.

Real-life examples: Google Docs, Microsoft Word for the web, Notion pages, Dropbox Paper, Confluence live editing

Requirements: scoping a shared document

"Build Google Docs" hides at least five products: a rich text editor, a sync engine, a presence system, a comment system, and a version history. Each one could fill an interview on its own. Your first job is to find out which of them the interviewer cares about, and how big and how live the problem is. Spend the first five minutes on questions whose answers move the design.

The questions below are in the order a senior engineer would ask them: first how many people edit at once and how big documents get, because those numbers pick the merging approach. Then what "live" means, what happens offline, and what a document can contain. The table shows the answer this guide assumes and which part of the design changes if the answer is different.

QuestionAnswer we assumeWhat it changes
How many people edit one document at once?Usually 1 to 5. Up to 100 on a busy shared doc, such as meeting notes.Whether we need real-time merging at all, and how presence scales.
How big can a document get?Up to about 300 pages, roughly 1 million characters, plus images.Load time, memory, layout cost, and how the text is stored in the model.
How live must it feel?Your own keystrokes show in the same frame. Others' edits show within about 300 ms on a normal connection.Edits apply locally first and sync in the background. Polling is out.
Does it work offline?Yes. People keep typing for hours without a connection, and their work merges when they're back.Edits are queued on the device and must merge after long gaps.
What can a document hold?Paragraphs, headings, lists, bold and italic, links, images, and simple tables.The document model and how many kinds of operations we must merge.
Who can do what?Owner, editor, commenter, and viewer roles, set per person or by link.The server checks every change. The client hides tools it can't use.
What history is needed?Undo of your own changes, and a list of past versions you can view and restore.Undo must ignore other people's work. The server keeps a log of every change.
A wireframe of the editor in a browser window at docs.example.com/d/q3-plan. A toolbar has Bold, Italic, and Comment buttons, and on the right three avatars: A, B, and plus 3. Below is the page, titled Q3 plan. The words on Friday in the line Ship the beta on Friday are highlighted because a comment is anchored to them. Further down, a caret with a flag saying Bob sits at the end of the line Draft owner: Asha. Notes on the right say: presence shows who is here; a comment is anchored to a range; the remote caret moves with edits; the page is rendered from the model.docs.example.com/d/q3-planBICommentAB+3Q3 planShip the betaon FridayBudget is 40k, see table.BobDraft owner: AshaOwners: Asha, Bob, Chenpresence:who is herecomment ona rangeremote caretmoves with editspage drawnfrom the model
Every part of the screen maps to a later chapter: the avatars and Bob's caret are presence, the highlight is a comment anchor that must follow its words, and the page itself is drawn from a model, not edited directly.

Functional requirements

  • Open a document and start reading within about 2 s, even if it has 300 pages.
  • Type and format text (headings, lists, bold, links, images, tables), with every keystroke showing instantly.
  • See other people's edits appear live, and see who is in the document and where their cursors are.
  • Select a range of text and leave a comment on it. The comment stays attached to that text while others edit around it.
  • Undo and redo your own changes, without undoing anyone else's.
  • Browse earlier versions, see who changed what, and restore an old version.
  • Keep editing without a connection, and have the changes merge when the connection returns.

Non-functional requirements, with numbers

Non-functional requirements are qualities, not features. Each one needs a number, because "fast" and "reliable" can't be tested and a number can. For an editor, the most important number is input latency: the time from a key press to the letter on screen. Typing is the one thing users do thousands of times per session, and any lag there feels broken.

  • Local echo in one frame. A typed character appears within 16 ms, the length of one frame at 60 frames per second. Heavier actions, like pasting 10 pages, stay under 200 ms (INP, Interaction to Next Paint).
  • Remote edits within 300 ms. When two people are online, each sees the other's change within about 300 ms at the 75th percentile, and within 1 s at the 99th.
  • Convergence. When everyone stops typing and all messages arrive, every copy of the document is exactly the same. No exceptions.
  • No lost keystrokes. Once a change shows as saved, it survives a crash. Changes not yet saved survive a closed tab or a dead battery, because they're also stored on the device.
  • Scale per document. 100 people editing, each typing up to 8 characters per second. That's up to 800 keystrokes per second for the server to order on one document.
  • Big documents stay smooth. Scrolling a 300-page document stays near 60 frames per second, and typing on page 250 is as fast as typing on page 1.
  • Accessible. Screen readers can read and edit the document, every command has a keyboard shortcut, and live changes from others never interrupt the reader.

Out of scope, said out loud

  • Suggestion mode (tracked changes). It's a layer on top of the same operations: a suggestion is an edit stored with an "unapproved" flag. Mention it, don't design it.
  • Spreadsheets and slides. They share the sync engine but have their own models.
  • Printing, PDF export, and pixel-perfect page layout. We break the document into pages, but we don't match a print engine.
  • The storage cluster behind the server. We describe what the server must guarantee (one order of changes per document), not how it replicates its disks.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes collaborative editing hard

A single-user editor is already a hard component: selections, IME input for languages like Japanese, paste, and layout. Collaboration adds problems of a different kind. They come from one fact: every person has their own copy of the document, and the copies change at the same time. Name these traits before you design, because each one turns into a decision later.

  • Nobody waits for the network. A keystroke must show in 16 ms, but a round trip to the server takes 50 to 300 ms. So each browser edits its own copy first and tells the others later. For a short time, the copies disagree.
  • Positions move. An edit says where it happens, such as "insert at character 120". If someone else just typed 5 characters earlier in the text, character 120 is now somewhere else. Every position (edits, cursors, comment ranges, undo steps) goes stale the moment someone else types.
  • Intent, not just equal copies. It's easy to make every copy equal: throw away all but one person's work. The real goal is to keep what each person meant. If Alice bolds a word while Bob fixes a typo in it, the result should be a bold, corrected word.
  • Rich structure. A document is not one long string. It has paragraphs, list items, tables, images, links, and comments. Each kind of change (split a paragraph, add a table row, bold a range) must merge with every other kind.
  • Long lives. Documents live for years and sessions last hours. History grows forever, and a person can come back from a week offline with 500 changes made on an old copy.
  • Big and busy. A 300-page document with 100 people in it must still type and scroll smoothly on an ordinary laptop.

The figure shows the core problem in its smallest form. Two people start with the same text and each makes one edit at the same moment. If each browser simply applies the other person's edit as it arrives, the copies end up different, and they stay different forever. Every system in this guide is an answer to that picture.

Two copies diverge. Both start with buy milk at revision 10. Alice inserts oat at 4 and gets buy oat milk. Bob inserts an exclamation mark at 8 and gets buy milk!. Each applies the other's op exactly as sent. Alice applies insert ! at 8 and gets buy oat !milk. Bob applies insert oat at 4 and gets buy oat milk!. The copies are different forever.buy milkboth, rev 10buy oat milkAlice: ins(4, "oat ")buy milk!Bob: ins(8, "!")buy oat !milkapplies Bob's op rawbuy oat milk!applies Alice's op rawdifferent, forever
Bob's insert "!" at 8 was right for his copy, but Alice's copy has 4 more characters before that spot. Positions are only true for the document they were made on.

Think first

Alice and Bob both start with buy milk. Alice inserts oat at position 4. Bob adds ! at position 8. Each browser applies the other's edit exactly as received. What does each person see?

What to send: the whole document or the change

Before deciding how to merge, decide what travels over the network. The naive answer is the one most apps use for forms: when something changes, save the whole thing. A 300-page document is about 1 MB of text and formatting, so saving it every 2 seconds while someone types is 0.5 MB per second of upload for one person. With 20 people typing, the server receives 10 MB per second for a single document, and almost all of it is text that didn't change.

Size is the smaller problem. The bigger one is that a whole document says nothing about what changed. If Alice's save and Bob's save arrive one after the other, the server only sees two different documents. It can keep one and lose the other, or compare them, which is the second option below.

Three payloads compared for a 1 MB document. Sending the whole document costs 1 MB per save, no CPU, and can't say what changed. A text diff costs about 200 bytes but needs a diff of 1 MB on each save, and only guesses what changed. An operation costs about 60 bytes per keystroke, no CPU, and says exactly what changed.bytes per sendCPU per savewhat changed?whole doc1 MBnoneunknowntext diffabout 200 Bdiff 1 MBa guessoperationabout 60 Bnoneexactly1 MB document, 8 keystrokes per second
Operations win on every column. A diff is small on the wire, but it pays in CPU and it can only guess at intent, which merging, cursors, and comments all need.

Decision What should each browser send when the user changes the document?

The whole document

On each save, send the full document. The server stores it as the new version.

Pros

  • Very simple. Any save restores the full state.
  • No merging code at all.

Cons

  • About 1 MB per save on big documents, sent many times a minute.
  • Two people's saves overwrite each other. The server can't tell who changed what.
  • Live updates mean resending 1 MB to every viewer.

A diff between two snapshots

Keep the last saved copy. On save, compute a text diff between it and the current copy, and send only the diff.

Pros

  • Small messages.
  • Works with any editor, even one that doesn't report its changes.

Cons

  • Diffing a 1 MB document on each save costs CPU time on the main thread.
  • A diff guesses at intent. If aa becomes aaa, the diff can't know which a is new, and that matters for cursors and merges.
  • Formatting and structure changes come out as messy text changes.

Operations captured at input

The editor turns each user action into an operation (often shortened to op): insert this text here, delete this range, bold that range. The op is applied locally and sent as is.

Pros

  • Tiny messages: a keystroke is about 60 bytes.
  • Exact intent: the op says what the user did, not what the result looks like.
  • The same ops feed undo, history, cursors, and comments.

Cons

  • Every action, including paste and formatting, must produce a correct op.
  • Ops depend on the document they were made on, so they need transforming when others edit at the same time.

What we pick: Operations. They're the smallest thing that can travel, and they carry what the user meant, which merging needs. Their one weakness, that an op's positions depend on the document it was made on, is exactly the problem the next chapters solve. Whole-document saves survive in one place: periodic snapshots on the server, so loading doesn't replay years of ops.

What an operation looks like

Most editors describe a change as a walk through the document from the start: keep (retain) some characters, insert some, delete some, keep the rest. Quill's Delta format and many OT libraries work this way. The walk form is handy because it describes the whole document in one pass, and it composes: two walks in a row can be folded into one. For explaining merges, the simpler form with a position is enough, so this guide uses both, as below.

ts
// Position form: easy to read and explain.
type Op =
  | { type: "insert"; pos: number; text: string; attrs?: Marks }
  | { type: "delete"; pos: number; length: number }
  | { type: "format"; pos: number; length: number; attrs: Marks };

type Marks = { bold?: boolean; italic?: boolean; link?: string | null };

// Walk form (like Quill Delta): retain, insert, delete in order.
type Step = { retain: number; attrs?: Marks } | { insert: string; attrs?: Marks } | { delete: number };

const boldThenBang: Step[] = [
  { retain: 10 },
  { retain: 5, attrs: { bold: true } }, // bold characters 10 to 15
  { insert: "!" },                      // then type "!" at 15
];
Two equivalent ways to write "bold the word at 10 to 15, then type ! at 15".

Structure needs ops too. Pressing Enter in the middle of a paragraph is a split at a position. Backspace at the start of a paragraph is a merge with the previous one. Changing a paragraph into a heading sets a block attribute.

Every new op type you add must merge correctly with every other one, so the list of op types is a cost. Keep it short: insert, delete, format text, and set block attributes cover most of a document.

Check your understanding · Exercise 2

Why do operations beat text diffs for a collaborative editor? Select all that apply.

Concurrency: who wins when two people type at once

Now take a concrete case. Alice and Bob both open revision 40 of a design doc. Alice fixes a typo in paragraph 2. At the same moment, Bob adds a sentence to paragraph 9.

Neither edit touches the other's text. A good design keeps both without anyone noticing. There are four families of answers, and the first three are what most web apps already use for other data.

Each one is worth naming in the interview, because each is right for some data. Sharing settings, the document title, and comment text are all edited by one person at a time, and simple rules work for them. The body text is where the simple rules break.

Decision How should the system handle two people changing the document at the same time?

Last write wins

Each save replaces what's on the server. Whoever saves last decides the content.

Pros

  • Trivial to build. Fine for a field one person edits, like a profile name.

Cons

  • Silent data loss: Bob's save, based on revision 40, overwrites Alice's typo fix.
  • Gets worse with more people. With 5 people typing, most saves erase someone.

Locks

Before editing, a client asks the server for a lock on the document or on one paragraph. Others can't edit what's locked until it's released.

Pros

  • No conflicts can happen, so no merging code.
  • Paragraph locks let people work in different places at once.

Cons

  • Asking for a lock is a round trip (50 to 300 ms) before the first letter can appear, which breaks the 16 ms echo.
  • Two people in the same paragraph take turns, which is common in meeting notes.
  • A laptop that sleeps while holding a lock blocks everyone until the lock times out.

Version checks

Each save says which version it was based on, for example with If-Match: "v40". If the server has moved on, it rejects the save with 412 Precondition Failed, and the client must reload, merge, and retry.

Pros

  • Never loses data silently.
  • Standard HTTP. Great when conflicts are rare, like editing a title or settings.

Cons

  • Under real-time typing, conflicts are constant: with 5 people saving every second, nearly every save is stale.
  • "Reload and merge" is the hard problem, just pushed to the user or to later.

Real-time merging

Every change is a small op, applied locally at once and sent to the server. When ops were made at the same time, an algorithm (OT or a CRDT) combines them so both survive and every copy ends the same.

Pros

  • No lost work and no waiting: everyone types freely.
  • Each person's keystrokes show in one frame.
  • Edits in different places merge without anyone noticing.

Cons

  • The merging code is hard to get right and hard to test.
  • Merges are correct but not always what people meant, as in the teh example below.

What we pick: Real-time merging for the document body. It's the only option that meets both the 16 ms echo and "no lost keystrokes" with several people typing. Keep version checks for low-contention data around the document (title, sharing settings, a comment's text), and keep the idea of a version number: the server's revision counter becomes the backbone of the merging protocol.

Four concurrency models compared when Alice and Bob edit at the same moment. Last write wins loses work silently, never waits, and doesn't fit live text. Locks lose nothing but make people wait, and fit live text poorly. Version checks lose nothing but force a retry on 412, and don't fit live text. Real-time merging loses nothing, never waits, and fits live text.lost work?waiting?live text?last write winsyes, silentlynonolocksnoyespoorlyversion checksnoretry on 412noreal-time mergenonoyesAlice edits paragraph 2, Bob paragraph 9, same moment
Only merging gets all three right. The others are still useful for data that one person edits at a time, like sharing settings.

Merging keeps work, not always meaning

Real-time merging promises that nobody's keystrokes vanish. It can't promise that the result reads well.

Say the text is teh and both Alice and Bob fix it to the at the same moment, each by deleting eh and typing he. Both deletes remove the same characters, so one of them becomes a no-op. Both inserts survive, because each is someone's typing. The merged text is thehe.

That's the right outcome. The system can't know the two people meant the same fix, and keeping both is better than silently dropping one. A human sees thehe and fixes it in one second, and live cursors make it obvious why it happened.

In the interview, say this out loud: merging guarantees convergence and keeps everyone's input, and people fix the rare semantic clash. That framing shows you understand the limits of the algorithms, not just their names.

Check your understanding · Exercise 3

Which data in a document editor is fine with a simple version check (If-Match) instead of real-time merging? Select all that apply.

Operational transformation: rewriting late operations

Operational transformation (OT) keeps ops as plain "insert at position" and "delete at position" instructions, and fixes the positions when ops cross. The fix is a function, usually called transform. It takes an op a and another op b that was applied first, and returns a': the same intent, rewritten so it applies correctly after b.

The one rule transform must follow is called TP1 (transformation property 1). Start from the same document. Path one applies a, then b', and path two applies b, then a'. Both paths must give the same document.

If that holds for every pair of op types, two people who apply each other's ops in either order end up with the same text. The diamond in the figure is the picture to draw on the whiteboard.

The transform diamond. At the top, buy milk at revision 10. On the left, after Alice's op a, insert oat at 4: buy oat milk. On the right, after Bob's op b, insert ! at 8: buy milk!. From the left, applying b prime, insert ! at 12, reaches buy oat milk!. From the right, applying a prime, insert oat at 4, reaches the same buy oat milk!.buy milkrev 10buy oat milkafter abuy milk!after bbuy oat milk!same on botha: ins(4, "oat ")b: ins(8, "!")b': ins(12, "!")a': ins(4, "oat ")
TP1 in one picture: a then b' equals b then a'. Here b' moved 4 places right; a' didn't move, because Bob's edit was after Alice's spot.

Four worked examples

In each one, Alice and Bob start from the same text at server revision 10, and Alice's op reaches the server first. Count positions from 0: in buy milk, the space is at 3 and m is at 4. Work each one by hand before reading the answer, then step through them in the lab below.

  1. Inserts in different places. Text buy milk. Alice inserts oat at 4. Bob inserts ! at 8. Alice's 4 characters land before Bob's spot, so Bob's op becomes insert ! at 12. Alice's op lands before Bob's, so it doesn't change. Both get buy oat milk!.
  2. Inserts at the same spot. Alice inserts 2 at 4 and Bob inserts oat at 4. Neither position is "before" the other, so a tie rule decides, and both sides must apply the same rule. The code below keeps the op with the lower client id on the left, and Alice's id is lower. Bob's op becomes insert at 6. Both get buy 2 oat milk.
  3. Insert after a delete. Text buy oat milk. Alice deletes buy (4 characters at 0). Bob types s at 7, to make oats. The 4 characters before Bob's spot are gone, so his op becomes insert at 3. Alice's delete is before Bob's insert, so it doesn't change. Both get oats milk.
  4. Overlapping deletes. Text buy oat milk. Alice deletes oat (4 at 4). Bob deletes buy oat (8 at 0). Bob's op loses the 4 characters Alice already removed and becomes delete 4 at 0. Alice's op is entirely inside Bob's, so on Bob's side it becomes nothing. Both get milk.
Edits
Step 1 of 6

Alice and Bob both have the text at server revision 10.

Two people edit "buy milk" at the same moment, with transformation on. Step 1: Alice has "buy milk", the server has "buy milk" at revision 10, and Bob has "buy milk".server revision: 10Alicebuy·milkrev 10serverbuy·milkrev 10Bobbuy·milkrev 10transform on: late ops are rewritten past earlier ones
Alice and Bob both have the text at server revision 10.

One more case matters for intent. If Bob types inside a range that Alice deletes, the transformed delete must split into two deletes around his text. Otherwise Alice's delete would erase letters she never saw. That is how OT preserves intention: each person's op only ever affects the characters that person could see.

ts
type TextOp =
  | { type: "insert"; pos: number; text: string; client: string }
  | { type: "delete"; pos: number; length: number; client: string };

// Rewrite `op` so it applies to the document after `other` was applied.
function transform(op: TextOp, other: TextOp): TextOp[] {
  if (other.type === "insert") {
    const n = other.text.length;
    if (op.type === "insert") {
      const staysLeft = op.pos < other.pos || (op.pos === other.pos && op.client < other.client);
      return [staysLeft ? op : { ...op, pos: op.pos + n }];
    }
    if (other.pos <= op.pos) return [{ ...op, pos: op.pos + n }];
    if (other.pos >= op.pos + op.length) return [op];
    // `other` typed inside the range: delete around it, never over it.
    return [
      { ...op, pos: other.pos + n, length: op.pos + op.length - other.pos },
      { ...op, length: other.pos - op.pos },
    ];
  }
  const shift = (p: number) => (p <= other.pos ? p : Math.max(other.pos, p - other.length));
  if (op.type === "insert") return [{ ...op, pos: shift(op.pos) }];
  const end = Math.min(op.pos + op.length, other.pos + other.length);
  const overlap = Math.max(0, end - Math.max(op.pos, other.pos));
  const length = op.length - overlap;
  return length > 0 ? [{ ...op, pos: shift(op.pos), length }] : [];
}
Transform for plain text inserts and deletes. Ties go to the lower client id, so both sides agree without asking anyone.

Why OT wants a central server

With a server in the middle, every op gets a number in one global order: revision 11, 12, 13, and so on. A client sends its op with the revision it was based on, say baseRev: 10. If the server is at 13, it transforms the op past revisions 11, 12, and 13, saves it as 14, and sends it to everyone. Every transform happens along that one agreed order, so TP1 is all the system needs.

Without a server, peers receive ops in different orders, and transforms must also satisfy a second, much harder property (TP2): transforming an op past two concurrent ops must give the same op whichever order those two are taken in. Several published peer-to-peer OT algorithms were later shown to break it.

This is the main reason production OT systems, including Google Docs and the older Google Wave, put one server in charge of ordering each document. The design goes back to the Jupiter system from Xerox PARC in 1995: each client only ever syncs with the server, never with other clients.

Check your understanding · Exercise 4

Text pay rent is at revision 20. Alice inserts the at 4 (before rent). Bob deletes pay (4 characters at 0). The server receives Alice's op first. Which statements are true? Select all that apply.

CRDTs: giving every character an identity

OT fixes positions after the fact. A CRDT (conflict-free replicated data type) removes the need for positions in the first place.

In a sequence CRDT, every character gets a permanent, unique id when it's typed, usually the pair (client id, counter): Alice's 7th character is A7. An insert doesn't say "at position 12". It says "after the character with id A3". Ids never change, no matter what anyone else does, so there's nothing to transform.

The rules are designed so that applying the same set of ops in any order gives the same result. That's the property that lets CRDTs work without a central server: peers can sync directly, or after a week offline, in any order, and still converge. Libraries such as Yjs and Automerge package this up for JavaScript.

A sequence CRDT. The text tea has characters t, e, and a with ids A1, A2, and A3. Alice deletes a, which becomes a hidden tombstone. At the same moment Bob inserts m with id B1, after A3. Because A3 still exists as a tombstone, B1 finds its place, and both copies show tem.texttA1eA2aA3mB1after A3tombstone: hidden, keptAlice deletes a (A3)Bob adds m after A3both copies show temB1 still finds A3
Bob's insert names its left neighbor by id, not by position. The deleted A3 stays as a tombstone so that reference still works, on every copy, in any order.

Two inserts after the same character

If Alice and Bob both insert right after A3, both new characters claim the same left neighbor. A fixed rule orders them, for example by comparing their ids, and every client applies the same rule, so all copies agree. RGA (Replicated Growable Array) places the newer insert first. YATA, the algorithm inside Yjs, also records the right neighbor at insert time.

These details matter for intent. Both RGA and YATA keep a word typed left to right in one piece, because each letter is inserted after the one before it. Some simpler designs, such as Logoot, give each character a fractional position instead. There, two people typing oat and soy at the same spot can end up with their letters interleaved, like osoayt, which is converged but useless.

Tombstones: why deleted characters stay

When Alice deletes a character, Bob may at that very moment be inserting "after" it. If the character vanished from Alice's copy, Bob's op would arrive pointing at an id that no longer exists.

So deletes don't remove the character. They mark it as deleted and hide it. That hidden record is a tombstone. It stays as long as any client might still send an op that refers to it, which, with offline clients, can be forever.

What identities cost in memory

A naive sequence CRDT stores one object per character, with an id, a left neighbor id, a deleted flag, and the character. In JavaScript that's easily 50 to 100 bytes per character instead of 1 or 2. A 1 million character document would need 50 to 100 MB before counting tombstones, and a document that has seen years of rewriting may have more deleted characters than live ones.

Real libraries are far more compact. Text typed in one go by one person has consecutive ids (A8, A9, A10...), so Yjs stores the whole run as one item, and splits it only when someone edits in the middle. Deleted runs keep their structure but drop their content. Automerge stores its history in a compressed column format.

With these tricks, the overhead for normal documents is small. It still grows with editing history, and removing tombstones completely requires knowing that every copy has seen the delete, which is hard when some copies are offline.

OT with a central server and a sequence CRDT, side by side.
ConcernOT with a central serverSequence CRDT (like Yjs)
Needs a server to order edits?Yes, one per document.No. Peers can sync in any order.
Offline for daysWorks, but a long queue rebases against many ops on reconnect.Natural: merge whenever copies meet.
Memory per characterNone beyond the text.Ids and tombstones; small with run merging, grows with history.
Where the hard code livesTransform functions for every pair of op types.Inside the library; the app uses its shared types.
Permissions and validationThe server sees and can reject every op.Possible with a server relay, but peers can merge anything they receive.
Known usersGoogle Docs, ShareDB, CKEditor 5 collaboration.Yjs (used with Tiptap and many editors), Automerge.

Decision Which merging approach should this editor use?

OT with a central server

Clients send position-based ops with a base revision. One server process per document transforms late ops, numbers them, saves them to a log, and broadcasts them.

Pros

  • The document stays plain text and attributes, with no per-character metadata.
  • The server is already the place for permissions, history, and validation.
  • Proven at Google Docs scale.

Cons

  • Transform functions for rich text are hard and you own them.
  • Long offline sessions mean big rebases on reconnect.
  • Nothing merges without the server.

Sequence CRDT (Yjs or Automerge)

Each client holds a CRDT document. Updates are exchanged through a relay server, or peer to peer. Any copies merge in any order.

Pros

  • Offline and peer-to-peer come for free.
  • Mature open-source libraries with editor bindings.
  • The server can be a simple relay plus storage.

Cons

  • Per-character ids and tombstones cost memory and load time on huge documents.
  • Rejecting an edit after it merged is awkward: you can only undo it with another edit.
  • History and attribution depend on what the library exposes.

Server-ordered steps with client rebase

The approach of ProseMirror's collab module. The server accepts a batch of steps only if it was based on the latest version. Otherwise it rejects, and the client fetches the missing steps, rebases its own on top, and retries.

Pros

  • Very simple server: compare a version number and append.
  • The rebasing logic lives in one library, on the client.

Cons

  • Under heavy contention, clients retry often, which adds latency for busy documents.
  • Still a central server, so it shares OT's offline limits.

What we pick: OT with a central server, as Google Docs does. Our requirements already need a server that sees every edit: per-person permissions, a full revision log with authors, and up to 100 editors on one busy document. OT keeps the model plain and small for 300-page documents. If the interviewer stresses days of offline work or peer-to-peer, switch to a CRDT like Yjs and say why. In practice, many teams pick Yjs to avoid writing transforms, and that's a fine answer when you can name its memory and validation costs.

Check your understanding · Exercise 5

Why does a sequence CRDT keep a tombstone for each deleted character instead of removing it? Select all that apply.

The document model and rendering

Ops need something to apply to. The browser offers an obvious candidate: put contenteditable on a div, and the browser gives you a caret, selection, typing, IME input for languages like Chinese and Japanese, spell check, and screen reader support for free. The naive design treats that DOM as the document: let the browser edit it, and read the HTML back to save it.

That breaks quickly. Browsers produce different HTML for the same action: pressing Enter may create a div, a p, or a br depending on the browser and the context. Pasting from a word processor brings in hundreds of nested span tags with inline styles. The browser's own undo stack knows nothing about remote edits.

Worst of all, the DOM tells you what it looks like now, not what the user did: a MutationObserver reports changed nodes, not "Bob typed e at 41", so you're back to guessing with diffs. And when a remote op changes the DOM directly, the browser's caret and the IME composition in progress can jump or break.

Decision How should the editor turn the document into something people can see and type into?

Raw contenteditable as the source of truth

The DOM is the document. Save its HTML, and apply remote changes by editing the DOM.

Pros

  • Fastest to start. Typing, selection, IME, and spell check all work at once.

Cons

  • Different HTML per browser for the same action.
  • No reliable ops: you'd have to diff the DOM.
  • Remote edits fight the browser's caret and IME.

Model first, contenteditable as the input surface

A JavaScript model owns the document. The editor listens to beforeinput, usually cancels the browser's default change, turns the event into an op, applies it to the model, and re-renders the affected nodes. The DOM is only a view. ProseMirror, Lexical, Slate, and Tiptap all keep their own model like this, though each still lets the browser handle some input and reads the result back from the DOM.

Pros

  • Every change is an exact op, local or remote.
  • Keeps native selection, IME, spell check, and much of screen reader support.
  • Mature open-source libraries to build on.

Cons

  • IME composition can't be cancelled, so it needs a special path.
  • Layout is the browser's, so exact page breaks are hard.

Custom layout drawn to a canvas

The editor measures text and lays out lines and pages itself, draws them on canvas, and uses a hidden text input to receive keys and IME text. Google Docs moved to this in 2021.

Pros

  • Identical layout and page breaks in every browser.
  • Full control over rendering performance for huge documents.

Cons

  • You rebuild selection, caret, IME, spell check, find, and copy.
  • Screen readers see nothing unless you build an accessible text layer alongside.
  • A very large investment.

What we pick: Model first, with contenteditable as the input surface. It gives exact ops for collaboration while keeping the browser's text input, which is the hardest part to rebuild. A canvas renderer is the right call only for a team that needs print-accurate pages across browsers and can afford to rebuild selection, IME, and accessibility, which is Google's situation, not most teams'.

The editor's loop. The contenteditable input surface sends beforeinput to Commands, which turn it into an op on the Doc model. The Doc model feeds the Renderer, which updates only changed blocks in the DOM and restores the selection; the DOM is the same element as the input surface. The Doc model also sends local ops to the Sync client and receives remote ops from it.contenteditableinput surfaceCommandsevent to opDoc modelblocks and runsDOM viewselection restoredRendererchanged blocksSync clientops both wayssame elementlocal opskey press: beforeinput, op, model, one block re-renderedremote op: sync client, model, the same render path
The DOM is only a view. Local keys and remote ops take the same path: an op on the model, then one block re-rendered. That's why preventDefault() on beforeinput is the first thing the editor does.

The shape of the model

The model is a tree: a document holds blocks (paragraphs, headings, list items, images, tables). Text blocks hold runs: stretches of text with the same marks, like bold or a link. Every block has a stable id, which helps rendering (only the changed block re-renders) and comments. Ops still address the document with one flat integer position, counting each character and one step for each block boundary, so transform stays simple arithmetic.

ts
type Marks = { bold?: true; italic?: true; link?: string; comment?: string[] };

type Run = { text: string; marks: Marks };

type Block =
  | { id: string; type: "paragraph"; runs: Run[] }
  | { id: string; type: "heading"; level: 1 | 2 | 3; runs: Run[] }
  | { id: string; type: "listItem"; ordered: boolean; depth: number; runs: Run[] }
  | { id: string; type: "image"; src: string; width: number; height: number; alt: string }
  | { id: string; type: "table"; rows: { cells: { blocks: Block[] }[] }[] };

type DocModel = {
  id: string;
  rev: number;        // last server revision folded into this copy
  blocks: Block[];
};
The client's document model. The server stores the same shape in snapshots.

From key press to pixels

  1. The user presses e. The browser fires beforeinput with inputType: "insertText" and data: "e".
  2. The editor calls preventDefault(), reads the selection from the model (not the DOM), and builds the op: insert e at 41.
  3. The op is applied to the model, recorded for undo, and handed to the sync client. None of these wait for the network.
  4. The renderer updates only the changed block's DOM and puts the selection back where the model says it is. Total time: a few milliseconds.
  5. IME input is the exception. During composition (compositionstart to compositionend), insertCompositionText events can't be cancelled, so the editor lets the browser change the DOM, then reads the final text and turns it into an op when composition ends. Remote ops that arrive meanwhile are held or carefully mapped, so they don't break the composition.

Check your understanding · Exercise 6

Why does the editor keep its own model instead of treating the contenteditable DOM as the document? Select all that apply.

The sync protocol: local first, then the server

We have ops, a model, and OT with a central server. The sync protocol is the set of rules that ties them together. The naive version sends every op the moment it's made and applies every op from the server the moment it arrives. That diverges for the reason we saw in the second chapter: an op from the server was made on a document that didn't include your newest keystrokes, and your keystrokes were made on a document that didn't include it.

The fix is for each client to track exactly which of its ops the server has seen. The design that does this was popularized by Google Wave and the ot.js library, and it keeps every client to a very simple contract: at most one batch of ops in flight to the server at a time.

Three states: synced, in flight, and pending

  • Synced. The local document equals server revision rev. Nothing is waiting.
  • In flight. The client sent one batch of ops based on rev and is waiting for the server's ack. Its local document is rev plus that batch.
  • In flight plus pending. The user kept typing while waiting. New ops are composed into one pending batch, which is not sent until the in-flight one is acked.

Three events move the client between states. A local edit applies to the model at once and joins the pending batch, which is sent right away if nothing is in flight. An ack sets rev to the revision the server gave the batch, clears in flight, and sends pending if there is any.

A remote op from the server was ordered before everything the client hasn't had acked, so the client transforms the remote op past its in-flight batch and then its pending batch, applies the result, and transforms its own two batches past the remote op so they stay correct. The server does the matching transform when the in-flight batch arrives, so both sides land on the same text.

ts
class SyncClient {
  rev = 0;                       // last server revision applied here
  seq = 0;                       // per-client counter, for dedupe
  inflight: Op[] | null = null;  // sent, waiting for an ack
  pending: Op[] = [];            // typed since, not sent yet

  onLocal(ops: Op[]) {
    this.pending = compose(this.pending, ops);
    if (!this.inflight) this.flush();
  }
  onAck(rev: number) {
    this.rev = rev;
    this.inflight = null;
    this.flush();
  }
  onRemote(rev: number, ops: Op[]) {
    let theirs = ops;
    if (this.inflight) [this.inflight, theirs] = transformPair(this.inflight, theirs);
    [this.pending, theirs] = transformPair(this.pending, theirs);
    this.rev = rev;
    this.model.apply(theirs);    // also maps cursors and comment anchors
  }
  private flush() {
    if (this.pending.length === 0) return;
    [this.inflight, this.pending] = [this.pending, []];
    this.socket.send({ type: "op", baseRev: this.rev, seq: ++this.seq, ops: this.inflight });
  }
}
The client side of the protocol. transformPair(mine, theirs) returns both ops rewritten past each other.

You type at the end of the line. Bob types at the start. Type a few letters, then let the server ack.

The sync client. Your screen shows "Plan" at revision 20. In flight: none. Pending: none. The server has "Plan" at revision 20. You typed 0 letters and sent 0 messages. Status: Saved.your screenPlanrev 20in flightnonependingnoneserverPlanrev 20letters typed: 0messages sent: 0status: Savedcopies match
You type at the end of the line. Bob types at the start. Type a few letters, then let the server ack.

What the server guarantees

The server side is short to describe but carries the correctness. Each document is owned by exactly one server process at a time, chosen by hashing the document id, so ops for one document are handled one after another, never in parallel. For each incoming batch, the server transforms it past every revision after its baseRev, appends it to the log as the next revision, waits for the write to be durable, then acks the sender and broadcasts to everyone else.

Two details prevent rare bugs. First, the server remembers the last seq it applied per client. A client that reconnects resends its in-flight batch, because it never saw the ack. If the server already applied it, it re-sends the ack instead of applying it twice.

Second, the WebSocket runs over TCP, so messages on one connection arrive in order. A client never sees revision 15 before revision 14, and if a gap appears after a reconnect, it asks for the missing range.

The op protocol as a sequence diagram between Alice, the collab server, and Bob. Alice sends insert oat at 4, based on revision 10. Bob sends insert ! at 8, also based on 10. The server acks Alice as revision 11 and sends her op to Bob. It then transforms Bob's op past revision 11 into insert ! at 12, sends it to Alice as revision 12, and acks Bob with revision 12.AliceCollab serverBobins(4, "oat "), base 10ins(8, "!"), base 10ack: rev 11rev 11: ins(4, "oat ")rev 12: ins(12, "!")ack: rev 12
Bob's op said baseRev: 10 but the server was at 11, so it moved 4 places right before it was saved and sent on. Each client waits on at most one ack at a time.

Batching and operation granularity

A fast typist makes 8 keystrokes per second. With 100 people on a document, one op per keystroke is up to 800 messages per second into the server, and each one is broadcast to 99 others. Batching cuts that, but a fixed delay adds latency everyone can feel. The question is how to batch.

Decision How should the client group keystrokes into messages?

One message per keystroke

Send every op the moment it's made, without waiting for acks.

Pros

  • Lowest latency for others.

Cons

  • Many tiny messages, each with headers and a server transform.
  • Several unacked batches in flight make the client state complex.

Fixed time window

Collect ops for, say, 200 ms, compose them, and send one message.

Pros

  • Predictable message rate.

Cons

  • Adds up to 200 ms of delay even on a fast network.
  • Still allows several batches in flight on a slow network.

One in flight, compose the rest

Send immediately if nothing is in flight. Otherwise compose new ops into the pending batch and send it when the ack arrives.

Pros

  • Adapts to the network: on a 50 ms round trip, batches are 1 or 2 keystrokes; on a 600 ms mobile link, about 5.
  • The simple three-state client above.
  • Never more than one round trip of extra delay.

Cons

  • On a very busy document, the message rate still follows typing speed on fast networks, so the server may ask clients to slow down.

What we pick: One in flight, compose the rest. Batch size follows the round-trip time with no tuning, and the client logic stays the three states above. For busy documents, the server can send a hint like minInterval: 100 to stretch batches when 50 or more people are typing.

Composing is what keeps batches small. Insert h at 40 followed by insert e at 41 composes into insert he at 40. Typing a whole sentence during one slow round trip becomes one insert. Deletes compose the same way.

Note that network batching and undo grouping are separate decisions: a batch is about efficiency, while an undo step is about what the user thinks of as one action. Don't let one decide the other.

Check your understanding · Exercise 7

Bob's client has a batch in flight and a pending batch. A remote op arrives from the server. Which steps are correct? Select all that apply.

Presence and remote cursors

Presence is who is in the document right now, and where: their name, color, cursor, and selection. It looks like document data, but it behaves differently. It changes many times a second, it's only useful for a few seconds, and nobody needs it in the history, so it doesn't go through the op log. It travels on the same WebSocket as a separate message type, and the server keeps it only in memory.

A cursor is a position, so it goes stale like an op does. Say Bob's caret is at 5, at the end of a car, and Alice types red at 2, so the text becomes a red car. On Carol's screen, position 5 is now right after red, so Bob's caret jumps to the wrong word unless something moves it to 9. Waiting for Bob's next presence message isn't good enough: Bob might be reading and not moving at all.

Decision How do remote cursors stay in the right place while text changes around them?

Wait for the next presence update

Draw each remote cursor where its last message said, until a new message arrives.

Pros

  • No extra code.

Cons

  • Cursors drift into the wrong words whenever anyone types, and stay wrong while their owner is idle.

Transform cursors with every op

Treat each remote cursor as a tiny insert at its position. Whenever any op is applied, local or remote, map every cursor through it, the same way the transform maps positions.

Pros

  • Cursors move with the text, even when their owner is idle.
  • Reuses the transform code.

Cons

  • Presence messages must say which revision their position refers to, so late ones can be mapped forward.

Anchor to character ids

With a CRDT, store the cursor as "just after character B14". Yjs calls these relative positions.

Pros

  • Never needs mapping: the id moves with its character.

Cons

  • Only available with a CRDT model.
  • If the character is deleted, the anchor must fall back to a neighbor.

What we pick: Transform cursors with every op, since we use OT. Every presence message carries rev. If it's older than the local revision, the client maps it forward through the ops since then, which it keeps for a short window (the last few hundred). If it's too old, the client drops it and waits for the next one.

Bob's caret is at the end of a car, position 5. Alice inserts red and a space at position 2, making a red car. If other screens keep Bob's caret at position 5, it lands after red, the wrong spot. If they map it through Alice's op, it moves to position 9, the end of the text, the right spot.beforea·carBobcaret at 5index kepta·red·carBobwrong spotmappeda·red·carBobright spot
A remote cursor is a position, so it goes stale exactly like an op. Map it through every op as soon as the op is applied, instead of waiting for Bob's next presence message.

Keeping presence traffic in check

Presence is the chattiest part of the system. If 100 people each send 10 cursor updates per second, the server receives 1,000 messages per second, and relaying each to 99 others is 99,000 messages per second for one document. Three rules keep it sane.

  • Throttle at the source. Send at most one update every 100 ms, always including the final position (a throttle with a trailing call). On documents with more than 20 people, the server tells clients to slow to one every 500 ms.
  • Batch at the server. Instead of relaying each message, the server sends each client one combined presence message every 100 ms with everyone who moved. That's 10 messages per second per client, regardless of the crowd.
  • Expire, don't trust. Each client sends a heartbeat every 15 s. The server drops anyone silent for 45 s or whose socket closed, and tells the others. When someone joins, the server sends them the full list.

On screen, remote carets and selections are drawn in an overlay layer above the text, positioned from the model and the layout, never inserted into the editable DOM where they'd confuse the caret and screen readers. Each caret has a name flag that shows while its owner is active and fades after about 3 seconds of no movement. Colors come from a fixed palette of about 10 distinct hues, and the flag always shows the name, so color is never the only clue. With 100 people, show 4 avatars and a "+96" button that opens the full list, and only draw flags for cursors in or near the viewport.

Check your understanding · Exercise 8

Which statements about presence are true? Select all that apply.

Comments anchored to ranges

A comment belongs to a piece of text: "Friday or Monday?" on the words on Friday. The thread itself (author, body, replies, resolved or not) is ordinary data, so the hard part is the anchor. While the comment is open, people keep typing before, inside, and over the commented words. The highlight must stay on the right words, grow when someone types inside them, and do something sensible when they're deleted.

The naive anchor is a pair of offsets saved with the thread: characters 120 to 129. That's a position, so it goes stale with the first keystroke above it. It's the cursor problem again, except comments last for weeks instead of seconds, and the people who see them may open the document long after the edits happened.

Decision Where should a comment's anchor live so it follows its text?

Offsets stored with the thread

Store start and end with the revision they refer to. The server maps them forward through every op, like it transforms ops.

Pros

  • Comments stay fully outside the document model.

Cons

  • The server must update every open thread on every op.
  • Undoing a delete can't bring the anchor back.

A mark inside the document

Add a comment mark with the thread id to the commented text, through a normal format op. The thread data lives in a separate comments store, keyed by the same id.

Pros

  • The anchor moves with its text through OT for free, including undo, copy within the document, and offline edits.
  • Overlapping comments are just several ids on the same text.

Cons

  • Creating a comment needs two writes: the mark op and the thread record.
  • Comment marks need their own rules at the edges.

Quoted text with fuzzy matching

Store the quoted words and some surrounding text. Find the best match in the current document when rendering.

Pros

  • Works even on documents edited by tools that don't know about comments.

Cons

  • Ambiguous when the words repeat.
  • Slow on large documents and can jump to the wrong spot.

What we pick: A mark inside the document. Anchors then follow the text through every path the text takes, with no extra server work. Also store the quoted text on the thread when it's created, as a fallback to show if the anchored text is later deleted.

A comment anchored with a mark. At the start, the words on Friday in Ship the beta on Friday carry the comment Friday or Monday?. When someone inserts new before beta, the mark stays on on Friday, now further right. When someone deletes on Friday, the thread keeps its saved quote, on Friday, and shows it in the sidebar.startShipthebetaonFridayinsert beforeShipthenewbetaonFridayrange deletedShipthenewbetaFriday or Monday?quote: on Fridaytyping at the edges doesn't join the comment
The anchor is part of the text, so OT moves it with no extra work. The thread outlives its words: it keeps the quote it was created with, and undoing the delete brings the highlight back.

Edge cases that decide the feel

  • Typing at the edges. Text typed inside the range joins the comment. Text typed right at its start or end does not, or every word typed after a commented phrase would join the highlight. ProseMirror calls this a non-inclusive mark.
  • Creating a comment. The client generates the thread id, sends the mark op through the sync client, and sends POST /docs/:id/comments with the same id. If either fails, a retry with the same id is safe. A mark whose thread doesn't exist is hidden and cleaned up later.
  • All anchored text deleted. The thread stays and shows its saved quote with a note that the text was removed. If someone undoes the delete, the text comes back with its mark, and the highlight returns.
  • Sidebar layout. Each comment card wants to sit level with its anchor. Sort cards by their anchor's position, and place each one at its anchor's height or just below the previous card, whichever is lower. The selected thread gets its exact spot and the others shift around it. Recompute only for the pages on screen, when their layout changes.
  • Live updates. New threads, replies, and resolves arrive on the same WebSocket as small events, separate from document ops, and update the comments store.

Check your understanding · Exercise 9

A comment is anchored to on Friday with a comment mark. Which outcomes are correct? Select all that apply.

Undo and redo in a shared document

In a single-user editor, undo is a stack: push each change, pop to reverse it. In a shared document, the obvious version of that is a disaster. If Ctrl+Z reverses the last change to the document, Alice's Ctrl+Z deletes the sentence Bob just typed. People expect undo to reverse their own last action and nothing else, which is how Google Docs and every serious collaborative editor behaves.

So each person keeps their own stack. That fixes whose change is undone, but not where.

Alice typed quick at 4 in the fox. Bob then typed Note: at 0. Alice's undo should delete quick , but the stored inverse says "delete 6 at 4", and position 4 is now inside Note: . Applied as is, it deletes : the and leaves Notequick fox.

Decision What should Ctrl+Z do in a shared document?

One shared stack

Undo reverses the most recent op on the document, whoever made it.

Pros

  • Easy: the inverse of the last op always applies cleanly.

Cons

  • Undoes other people's work. Unusable with more than one person.

Your own stack, original positions

Each person stores inverses of their own ops and applies them as stored.

Pros

  • Only touches your changes, in theory.

Cons

  • After anyone else edits, the positions are stale and undo damages the wrong text.

Your own stack, transformed inverses

Each person stores inverses of their own ops. Every op applied after one, local or remote, transforms the stored inverse too. Undo applies the up-to-date inverse as a new op.

Pros

  • Reverses exactly your change, wherever it moved.
  • Undo is just another op: it syncs, merges, and appears in history like any other.

Cons

  • Every stack entry is transformed on every op, so cap the stack (100 steps is plenty).
  • Some undos become empty when others already changed that text.

Restore an earlier snapshot

Undo resets the document to how it was before your last action.

Pros

  • Simple to reason about alone.

Cons

  • Erases everything others did since then. Belongs in version history, not Ctrl+Z.

What we pick: Your own stack with transformed inverses. It's the only option that reverses just your change at its current position. The undo becomes a new op with your name on it, so others see it like any edit, and nothing in the shared log is rewritten.

Undo

Add a word as you, then a note as Bob, then press Undo.

Undo lab, mode mine, transformed. The document reads "the fox". History, oldest first: empty. Your undo stack: empty. Redo stack: empty.the shared document"the fox"history, oldest firstno edits yetundo stack: emptyredo stack: emptyundo reverses your change where it is now
Add a word as you, then a note as Bob, then press Undo.

How the stack works

  • Inverses. The inverse of an insert deletes the same range. The inverse of a delete inserts the removed text with its marks, which is why delete ops must carry the text they removed. The inverse of a format op restores the previous attributes.
  • Grouping. One undo step should match one human action. Consecutive typing joins the current step until a pause of about a second, a cursor jump, Enter, or a formatting command starts a new one. Paste and formatting are always their own steps.
  • Transforming. Each remote op is applied to the document and also used to transform every entry on the undo and redo stacks. Mapping 100 entries per op is cheap; keeping thousands is not, hence the cap.
  • Redo. Undo pushes the inverse of what it did onto the redo stack, also transformed by later ops. Any new edit by you clears the redo stack, as in every editor.
  • Empty undos. If Bob already deleted the text you typed, the transformed inverse deletes nothing. Skip it and undo the next step, so one Ctrl+Z never seems to do nothing.
  • Native shortcuts. The browser's own undo must not run. Handle beforeinput events with inputType of historyUndo and historyRedo, plus the undo and redo shortcuts (Ctrl+Z, Ctrl+Shift+Z, and Ctrl+Y, or Cmd on a Mac), and route them to your stacks.

Check your understanding · Exercise 10

Alice bolds a word. Then Bob, in another paragraph, types a sentence. Then Alice presses Ctrl+Z. What should happen? Select all that apply.

Version history: a log plus snapshots

People want to see the document as it was last Tuesday, find out who removed a paragraph, and restore an old version. The naive way is to save a full copy every few minutes. For a 1 MB document edited every day, that's hundreds of megabytes a year per document. It also loses everything between copies, and a copy can't tell you who changed what.

We already have something better: the server's log of every op, in order, with its author and time. Revision 1,234 is "the document after the first 1,234 ops". The log alone would work, but rebuilding revision 90,000 by replaying 90,000 ops from an empty document is slow. So the server also saves snapshots.

Decision How should the server store history so any version can be viewed quickly?

Full copies on a timer

Save the whole document every few minutes and keep the copies.

Pros

  • Viewing a copy is one read.

Cons

  • Huge storage for big documents.
  • Nothing between copies, and no authorship.

The op log alone

Keep every op. Build any revision by replaying from the start.

Pros

  • Exact history and authorship at no extra storage.

Cons

  • Viewing an old or recent revision of a long-lived document means replaying tens of thousands of ops.

Op log plus snapshots

Keep every op, and also save a full snapshot every 500 revisions or so. Build revision r from the nearest snapshot at or below r, then replay at most 499 ops.

Pros

  • Any revision in milliseconds.
  • Exact authorship from the ops.
  • Loading the live document uses the same path: latest snapshot plus the ops after it.

Cons

  • Snapshots cost storage; old ops can be compacted after a retention period.

What we pick: Op log plus snapshots. It's exact, it's fast to read, and it reuses the structure the sync protocol already writes. Opening the live document is just "latest snapshot plus the tail of the log", so one code path serves both.

The op log with snapshots. A snapshot at revision 88,000 is followed by ops 88,001, 88,002, and so on up to 88,120 and beyond; the next snapshot is at 88,500. To view revision 88,120, load snapshot 88,000 and replay 120 ops. To restore 88,120, append a new revision that turns the current document into it.op log, one revision per opnext snapshot: 88,500snap 88,00088,00188,002...88,120...replay 120 opsview 88,120:snapshot, then replayrestore 88,120:append a new revision
Snapshots bound the replay to at most 499 ops. Restoring never rewrites the log: it adds a new revision, so collaborators see it live and it can be undone.

From revisions to a history people can use

  • Group revisions. Nobody wants 90,000 entries. Group consecutive revisions into versions by time: start a new entry after about 30 minutes without edits. Each entry lists the people who edited in it.
  • Show changes by author. Ops carry their author, so the viewer can highlight inserted text in each author's color and show deleted text struck through, for the range of revisions in one entry.
  • Named versions. "Sent to legal" is a label pointing at a revision. It costs one row and protects that revision from compaction.
  • Restore as a new revision. Restoring version r computes the op that turns the current document into revision r and applies it as a new revision. Collaborators see it live, it can be undone, and nothing in the log is erased.
  • Retention. Keep every op for, say, 30 days. After that, keep the snapshots at version boundaries and named versions, and drop the ops between them. History stays useful while storage stays bounded.

On the client, history is a separate read-only view, such as /d/q3-plan/history?rev=88120. It fetches a snapshot and ops over REST, renders with the same renderer in read-only mode, and loads the version list page by page with a cursor. Keeping it separate means the editor never has to hold the whole history in memory.

Check your understanding · Exercise 11

Snapshots are saved every 500 revisions. A user asks to view revision 88,120. What does the server do? Select all that apply.

Offline editing and catching up

The requirement says someone on a train keeps typing for hours. Good news: the sync client already handles most of this, because ops made while the server is slow simply wait in the pending batch. Offline is the same situation stretched from 300 ms to 3 hours. What's missing is making the waiting ops survive a closed tab, and catching up with everything others did meanwhile.

The naive answers both fail the requirement. Making the editor read-only when the socket drops means no work on the train. Keeping the queue only in memory means a dead battery loses three hours of writing, which is the worst failure an editor can have.

Decision What should happen when the connection drops in the middle of writing?

Read-only while offline

Disable editing until the socket reconnects.

Pros

  • No merging after long gaps.

Cons

  • Fails the requirement. People lose the time they had.

Queue in IndexedDB, rebase on reconnect

Keep editing. Save the base document and the queued ops in IndexedDB. On reconnect, fetch the ops missed, transform the queue past them, and send it.

Pros

  • Uses the same transform path as normal sync.
  • Survives tab close, crash, and restart.

Cons

  • A very long queue against many missed ops can merge in surprising ways.
  • If the server already compacted the missed ops, catch-up needs a fallback.

Switch the model to a CRDT

Store the document as a CRDT on the device, and merge copies whenever they meet.

Pros

  • Built for long offline periods.

Cons

  • Changes the whole design, with its memory costs, for one requirement.

What we pick: Queue in IndexedDB and rebase on reconnect. It extends the protocol we already have instead of replacing it. If the interviewer pushes to "weeks offline, on many devices", that's the moment to say a CRDT becomes the better fit.

Catching up after being offline. The server op log shows revisions 95 to 104. This client last saw revision 100. Revisions 101 to 104 were made by others while it was offline. Its IndexedDB queue holds three ops based on revision 100. On reconnect it says hello with rev 100, receives 101 to 104, transforms its queue past them, and sends the queue as one composed batch, which the server saves as revision 105.server op logr95r96r97r98r99r100r101r102r103r104last seenothers, while offlineIndexedDB queue, base r100o1o2o3hello rev 100: receive r101 to r104transform the queue past themsend as one batch: saved as r105compacted past r100?snapshot, diff, rebase, or show the text to copy
Offline edits are just very late ops. The client runs the missed ops through its normal onRemote path, which rebases the queue. A batch that was in flight when the connection dropped is resent with its original seq, so it's never applied twice.

The catch-up, step by step

  1. Notice. The socket closes, or a heartbeat gets no reply within 10 s. navigator.onLine is only a hint: it can say online while nothing gets through. The save indicator switches to "Offline, changes saved on this device".
  2. Persist. While offline (and online too), write the document at the last acked revision, plus in-flight and pending ops, to IndexedDB. Write within about 500 ms of the last change and again on visibilitychange to hidden, which is the last event a mobile browser reliably fires.
  3. Reconnect with backoff. Retry after about 1 s, then 2, 4, 8, up to 30 s, with random jitter so a whole office coming back online doesn't reconnect in the same second.
  4. Say hello. Send hello with the document id, the client's rev, its client id, and the seq of the in-flight batch. The server replies with every op after rev.
  5. Rebase. If one of the missed ops is the client's own in-flight batch (same client id and seq), the server got it before the connection dropped, so the client treats it as the ack. Every other missed op runs through the same onRemote path, which transforms the in-flight and pending batches. Then the client resends in flight if the server never got it, and sends pending after the ack, as usual.
  6. Tell the person. A quiet note such as "You were offline for 3 h. 214 changes from 4 people were merged." with a link to the history view of that period.

When catch-up gets hard

  • Too far behind. If the server already compacted the ops after rev, it replies too-far-behind with the latest snapshot. The client computes a diff from its old base document to that snapshot, turns it into ops, and transforms its queue past them. Diffs guess intent, but they keep everyone's text. If even that fails, open the fresh document and show the offline text in a panel to copy from. Never drop it.
  • Access removed while offline. The server rejects the queue with forbidden. Keep the local text, explain what happened, and offer to copy or download it.
  • Opening documents offline. A service worker caches the app shell, and IndexedDB keeps the 20 most recently opened documents, so the train ride can start with a document that wasn't open. Call navigator.storage.persist() to make the browser less likely to evict that data.
  • Several tabs. Each tab is its own client with its own id and its own saved queue. If a tab closed with an unsent queue, the next tab to connect claims it with the Web Locks API (navigator.locks) and sends it, so it's sent exactly once.

Check your understanding · Exercise 12

Which choices protect three hours of offline writing? Select all that apply.

Putting it together: the architecture

With the big decisions made, the parts almost name themselves. The rule that shapes every boundary is this: the document model is the only thing that both the screen and the network touch.

Input turns into ops on the model. The renderer draws the model. The sync client sends ops from the model and applies remote ops to it. No part writes to the DOM or the socket except the one that owns it.

PartResponsibilityWhy it's separate
Input layerListens to beforeinput, keys, IME, paste, and turns them into commands.Browser quirks stay in one place.
Editor coreThe document model, commands that produce ops, and the undo manager.Pure logic: no DOM, no network, easy to test with fuzzing.
RendererDraws visible pages from the model, restores the selection, draws the cursor overlay.Can be swapped (DOM now, canvas later) without touching sync.
Sync clientThe three-state protocol, transforms, acks, revision numbers, reconnects.Network timing never leaks into the editor core.
Presence clientThrottled cursor updates, mapping remote cursors, heartbeats.Ephemeral and lossy, unlike document ops.
Comments storeThreads and replies from REST and live events; anchors come from the model.Different data, different lifetime, different API.
Local persistenceIndexedDB copy of the base document and queued ops, per client id.Survives tab close; shared by the offline flow.
Collab service (server)One owner process per document: transform, number, log, broadcast.The single ordering point OT needs.
Docs API (server)Load snapshots, history, comments, permissions over REST.Plain cacheable reads, separate from the live socket.
The architecture. In the browser tab, the Input layer turns beforeinput and IME events into ops on the Editor core, which holds the model and undo. The Editor core feeds the Renderer, which draws visible pages, and exchanges ops with the Sync client, which has three states. The Sync client saves the base copy and queue to IndexedDB. A Presence client handles cursors. Outside the browser, the Sync client talks to the Collab service over a WebSocket and to the Docs API over HTTP; the Presence client also uses the Collab service. The Collab service writes to the Op log, which holds ops and snapshots.Browser tabRenderervisible pagesInput layerbeforeinput, IMEEditor coremodel, undoSync client3 statesIndexedDBbase + queuePresencecursorsDocs APIsnapshots, historyCollab serviceorder, transformOp logops + snapshotsWebSocket
The editor core is the only part both the screen and the network touch. Only the sync client talks to the collab service, and only the collab service decides the order of ops.

Follow one keystroke through the picture. The input layer turns e into a command. The editor core applies insert e at 41 to the model and pushes the inverse onto the undo stack. The renderer redraws one paragraph.

The sync client adds the op to pending and, since nothing is in flight, sends it. About 80 ms later, the collab service saves it as revision 5,012 and broadcasts it. On Bob's screen, his sync client transforms it past his own pending ops, and his renderer redraws the same paragraph.

Check your understanding · Exercise 13

A remote op arrives that inserts a sentence above the local user's cursor. Which parts take part in handling it? Select all that apply.

Data model and interface

Each piece of client data has a natural home. Choosing the wrong one creates bugs: presence in IndexedDB is wasted writes, and queued ops only in memory is lost work. The table lists each entity, where it lives, and why.

EntityLives inWhy
Document modelEditor core memory, copied to IndexedDB at each ackEdited in memory every keystroke; the copy makes offline opening possible.
In-flight and pending opsSync client memory and IndexedDBMust survive a closed tab until acked.
Undo and redo stacksEditor core memory onlyPer tab and per session, like any editor; capped at 100 steps.
PresencePresence client memoryRebuilt from the server every few seconds; never persisted.
Comment threadsComments store, fetched and cached per documentServer data with its own updates; anchors live in the model as marks.
Revision listFetched per page in the history viewLarge and rarely read; never loaded in the editor.
Current revision in the URLOnly in history: ?rev=88120Shareable link to an exact version.

The live channel needs to send ops both ways, at high rates, in order, on one connection. That rules out a few familiar options, as the decision shows.

Decision How should ops and presence travel between the client and the server?

WebSocket

One long-lived connection per open document carries ops, acks, remote ops, presence, and comment events in both directions.

Pros

  • Both directions on one ordered connection.
  • Small frames: no HTTP headers per keystroke.

Cons

  • Needs sticky routing to the document's owner process and its own reconnect logic.
  • Some corporate proxies break long-lived connections, so keep a fallback.

Server-sent events plus POST

Remote ops stream down over SSE (EventSource); local ops go up as POST requests.

Pros

  • Plain HTTP, friendly to proxies and HTTP/2.
  • Built-in reconnection with Last-Event-ID.

Cons

  • Ops go up and down different channels, so the client must reorder acks and remote ops.
  • A request per batch adds header overhead.

Polling

Ask for new ops every second or two.

Pros

  • Simplest to deploy.

Cons

  • Fails the 300 ms requirement and wastes requests on idle documents.

What we pick: WebSocket, because the protocol depends on ops and acks arriving in one order on one connection. Keep SSE plus POST as the fallback for networks that block WebSockets: the protocol messages are the same, only the transport changes.

ts
// Client to server
type Up =
  | { type: "hello"; rev: number; clientId: string; inflightSeq: number | null }
  | { type: "op"; baseRev: number; seq: number; ops: Op[] }
  | { type: "presence"; rev: number; anchor: number; head: number }
  | { type: "ping" };

// Server to client
type Down =
  | { type: "ack"; seq: number; rev: number }
  | { type: "remote"; rev: number; author: string; ops: Op[] }
  | { type: "presence"; people: { clientId: string; name: string; color: string; rev: number; anchor: number; head: number }[] }
  | { type: "left"; clientIds: string[] }
  | { type: "comment"; event: "added" | "replied" | "resolved"; threadId: string }
  | { type: "access"; role: "editor" | "commenter" | "viewer" }
  | { type: "error"; code: "too-far-behind" | "forbidden" | "invalid-op"; snapshotRev?: number }
  | { type: "pong" };
Messages on the document socket, wss://collab.example.com/d/:docId.
http
GET  /docs/q3-plan                       -> { snapshot, rev, role, title }
GET  /docs/q3-plan/ops?after=5012          -> { ops: [...], rev }  (or 410 if compacted)
GET  /docs/q3-plan/revisions?cursor=abc    -> { versions: [...], nextCursor }
GET  /docs/q3-plan/revisions/88120         -> { snapshot, rev }
POST /docs/q3-plan/comments                -> 201 { threadId }  (Idempotency-Key header)
POST /docs/q3-plan/comments/t-9/replies    -> 201 { replyId }
PUT  /docs/q3-plan/permissions             If-Match: "v7" -> 200 | 412
REST endpoints for everything that isn't live.

Check your understanding · Exercise 14

Why does the protocol send ops over a WebSocket rather than as POST requests with remote ops over SSE? Select all that apply.

Large documents and performance

A 300-page document is about 1 million characters, roughly 2,000 paragraphs, and 300 pages of layout. Everything that's fine at 3 pages hurts at 300: rendering every paragraph creates hundreds of thousands of DOM nodes, laying out every page takes seconds, and storing the text as one big string means each keystroke can copy a megabyte. The goal from the requirements: typing on page 250 feels the same as on page 1.

Start with loading. The client fetches the latest snapshot (about 1 MB, around 300 KB compressed) plus the ops after it, while the service worker serves the app shell from cache. It lays out and paints the first two pages first, so the person can read and type within about 2 s, and lays out the rest in small chunks during idle time, yielding to input with scheduler.yield() where supported. The page count in the status bar says "about 300 pages" until that finishes.

Decision How should the model store the text so edits stay fast in huge documents?

One big string

The whole document's text in one string, with formatting stored as ranges beside it.

Pros

  • Simplest to search and to serialize.

Cons

  • Strings are immutable, so each insert builds a new string that can mean copying up to 1 MB.
  • One change invalidates everything that depends on the string.

An array of blocks

Each paragraph is its own block with a short text and its runs. An edit replaces one block.

Pros

  • An edit costs one paragraph, usually under 1 KB.
  • Blocks map to render units and layout units.
  • Unchanged blocks keep their identity, so rendering skips them.

Cons

  • Converting a flat position to a block needs an index of block lengths, kept up to date.

A rope or piece table

A balanced tree of text pieces, as code editors like VS Code use (a piece tree).

Pros

  • Fast inserts anywhere, even inside one giant paragraph.

Cons

  • More complex, and documents rarely have giant paragraphs.

What we pick: An array of blocks, plus a small tree of running block lengths so position lookups take about 11 steps for 2,000 blocks instead of a scan. Edits, rendering, and layout all work one paragraph at a time. A rope only pays off for code or log files with enormous single blocks.

Page virtualization in a 300-page document. Pages 1, 2, 299, 300 and the rest are empty placeholders with known heights. Page 120 is on screen, and pages 119 and 121 are mounted around it, so about 3 pages are in the DOM. An edit on page 120 relays out one paragraph; page breaks after it are fixed lazily.viewportp1p2...p119p120p121...p299p300mounted: pages 119 to 121edit on page 120:relayout 1 paragraphpages after it:breaks fixed lazily
The DOM holds about 3 pages whatever the document's length. Placeholders keep their computed heights, so the scrollbar is right and scrolling never jumps.

The per-keystroke budget

  • Incremental layout. An edit relays out only its paragraph. If the paragraph's height changes, later page breaks move, but that work is done lazily for pages off screen.
  • Page virtualization. Only the visible pages and one on each side are in the DOM, about 3 of 300. The rest are empty boxes with their computed height, so the scrollbar stays right. When layout isn't done yet, estimated heights are corrected while keeping the reader's position anchored.
  • Batch remote ops per frame. If 30 remote ops arrive in one burst, apply them all, then render once in the next requestAnimationFrame. Rendering after each would cost 30 layouts.
  • Move work off the main thread. Spell check, word count, and the find index run in a Web Worker that gets text changes per paragraph.
  • Bound memory. Cap the undo stacks at 100 steps, keep only the last few hundred ops for mapping late presence, and never load history in the editor.
  • Measure in the field. Track the time from beforeinput to the next paint per keystroke, and alert when the 75th percentile passes 16 ms. INP covers the heavier actions like paste.

Check your understanding · Exercise 15

Typing feels slow on page 250 of a long document but fine on page 1. Which causes are likely? Select all that apply.

Permissions and safety

Four roles cover most needs: owner, editor, commenter, and viewer, set per person or for "anyone with the link". The client uses the role to shape the interface: a viewer gets a read-only editor where selecting and copying still work, a commenter gets a read-only editor with the comment button enabled. That's convenience, not security. Anyone can open the browser console and send an op over the socket by hand.

So the collab service checks the role on every op. The socket authenticates with a short-lived token (about 10 minutes, refreshed in the background), and the service keeps each connection's current role in memory. There's one subtle case: a commenter must be allowed exactly one kind of document op, adding or removing a comment mark, because comment anchors live in the document. Anything else from a commenter is rejected with forbidden.

  • Role changes mid-session. When an owner turns Bob from editor into viewer, the server sends him an access message and rejects his later ops. His client switches to read-only and shows his unsent text in a panel to copy, instead of silently dropping it.
  • Access removed. The server closes the socket. The client deletes its IndexedDB copy of that document, because an offline copy is still a copy. Do the same for every cached document on sign-out.
  • Paste is untrusted input. Pasted HTML is parsed into the model's own blocks and marks, so a script tag or an onerror attribute has nowhere to live. Link marks accept only https, http, and mailto URLs.
  • Op limits. The server rejects ops larger than about 1 MB and clients sending far beyond human typing speed, which protects the document's owner process from a buggy or hostile client.

Check your understanding · Exercise 16

Which checks belong on the server, not only in the client? Select all that apply.

Accessibility in a live editor

An editor is a text box that thousands of people live in all day, including people who use screen readers, switch devices, or only a keyboard. Choosing a model-first editor with contenteditable as the surface pays off here: the DOM holds real text, real headings, real lists, and real tables, so screen readers can read and edit it with their usual commands. Render semantic elements (h1 to h3, ul, ol, li, table), and give the editable root role="textbox", aria-multiline="true", and a label from the document title.

Collaboration adds a problem single editors don't have: the text changes while someone reads it. Announcing every remote keystroke would make the document unusable with a screen reader. The rule is restraint: remote edits are applied silently, the local caret never moves because of them, and the person can ask for what's happening instead of being told constantly.

  • Remote cursors are decoration. Mark the overlay aria-hidden="true". Provide a keyboard shortcut that announces who's in the document and where ("Bob is in the heading Budget"), and a collaborator list that's reachable by Tab.
  • Rare, polite announcements. Use one aria-live="polite" region for joins and leaves (batched: "2 people joined"), going offline, coming back, and merge notices. Never announce "Saving..." on every keystroke.
  • A way out of the text. Tab inside a list indents it, so focus can get trapped. Document shortcuts to move focus to the menus, the toolbar, and the comments panel. The toolbar follows the WAI-ARIA toolbar pattern: one Tab stop, arrow keys inside.
  • Comments by keyboard. A shortcut adds a comment on the selection. When the caret is in commented text, the comment is reachable with one more shortcut and announced as "has comment".
  • Not color alone. Every remote cursor and history highlight comes with a name. Respect prefers-reduced-motion by not animating remote cursors as they glide.
  • Canvas needs a twin. If the renderer ever moves to canvas, screen readers see an image. Keep an off-screen accessible text layer in sync, which is the reason Google Docs has a separate screen reader support setting.

Check your understanding · Exercise 17

A screen reader user is reading paragraph 4 while two people type in paragraph 9. What should happen? Select all that apply.

Running it in the interview

This problem is too big to cover evenly in 60 minutes, and interviewers know it. What they watch for is whether you find the core (concurrent edits on copies that can't wait for each other), solve it with a real mechanism, and then go deep where they point you. The plan below spends the most time on the part that only this problem has.

A 60 minute plan. Minutes 0 to 5: ask scope questions. 5 to 12: the core problem. 12 to 27: merging, the longest part. 27 to 37: the parts and the model. 37 to 52: deep dives the interviewer picks. 52 to 60: wrap up. Notes: write the numbers 16 ms, 300 ms, and 100 editors; draw the diamond, the 3 states, and the server log; pick 2 or 3 deep dives.ask0-5core5-12merging12-27parts27-37deep dives37-52wrap52-6016 ms, 300 ms,100 editorsdiamond, 3 states,server logpick 2 or 3deep dives
A quarter of the time goes to merging, because it's the part only this problem has. Deep dives come after the core is on the board, so you can point back to it.
  1. Minutes 0 to 5: scope. Ask how many people edit at once, how big documents get, whether offline matters, and what a document contains. Write down 16 ms, 300 ms, and 100 editors.
  2. Minutes 5 to 12: the core problem. Draw two copies of buy milk diverging. Name ops as the unit of change, and walk through why last write wins, locks, and version checks fail for body text.
  3. Minutes 12 to 27: merging. Draw the transform diamond, work one example out loud, then the three-state client and the server's revision log. Compare with CRDTs in two sentences and say why you chose one.
  4. Minutes 27 to 37: architecture and model. The parts table, the model as blocks and runs, model-first contenteditable, and the message types.
  5. Minutes 37 to 52: deep dives. Let the interviewer pick: cursors, comments, undo, history, offline, or big documents. Each has a mechanism in this guide that you can explain in about 5 minutes.
  6. Minutes 52 to 60: wrap-up. Permissions on the server, accessibility, and what you'd measure. End by naming the riskiest part, the transform functions, and how you'd test them.

Draw in a fixed order so the board tells the story: first the two diverging copies, then the diamond next to them, then the client's three states with the server's log, and only then the boxes of the architecture. Each later topic (cursors, comments, undo, offline) is then a short arrow back to the diamond: "this is a position, so it gets transformed too".

Follow-up questions and strong answers

  • "Why not a CRDT?" Both work. We need a server anyway for permissions, history, and 100 editors, and OT keeps the model free of per-character metadata. A CRDT wins for days offline or peer-to-peer.
  • "What if the server crashes after receiving an op?" The server acks only after the op is durably in the log. A client without an ack resends with the same seq, and the new owner process either applies it or re-acks it.
  • "How do you test this?" Fuzz the transforms with random op pairs and check both paths match. Then run simulated clients with random delays and disconnects and assert every copy ends identical.
  • "What about 1,000 viewers on a company announcement?" Viewers don't send ops, so the owner process isn't the bottleneck. Fan the op stream out through a separate broadcast tier, and cap presence to a viewer count instead of 1,000 cursors.
  • "Someone pastes 50 pages." It's one op, under the 1 MB limit, applied to the model at once. Layout and rendering run in chunks, with the visible page drawn first, so the paste stays within the 200 ms INP budget.
  • "How would suggestion mode work?" Suggestions are ops stored with a "suggested by" mark instead of applied text. Accepting one is a new op that clears the mark; rejecting one is a new op that removes the text.

Check your understanding · Exercise 18

You have 15 minutes left and the interviewer says "tell me about offline". Which answer shows the most depth? Select all that apply.

Summary

  • Send operations, not documents: they're tiny, they carry what the user meant, and the same ops drive sync, undo, history, cursors, and comments.
  • Last write wins, locks, and version checks each fail live body text: lost work, waiting, or constant conflicts. Keep version checks for low-contention data like sharing settings.
  • OT rewrites late ops so both orders give the same text (TP1). A central server that numbers every op is what keeps OT simple; ties need one rule that every side applies.
  • Sequence CRDTs give each character a permanent id, so merges need no server, at the cost of per-character metadata and tombstones. Pick them for long offline or peer-to-peer.
  • Keep a model that owns the document, with contenteditable as the input surface fed by beforeinput, and render from the model.
  • The client has three states (synced, in flight, in flight plus pending) and one batch in flight. Remote ops are transformed past both local batches, and batches size themselves to the round trip.
  • Anything holding a position (cursors, comment anchors, undo entries) must move with every op. Comments anchor as marks inside the text; undo uses each person's own transformed inverses.
  • History is the op log plus snapshots. Offline is the pending queue saved to IndexedDB and rebased on reconnect. Big documents need blocks, incremental layout, and page virtualization. The server enforces every permission.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next