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.
| Question | Answer we assume | What 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. |
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.
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?
Alice has buy oat milk. Bob's edit says "insert ! at 8", and position 8 in her text is between oat and milk, so she sees buy oat !milk.
Bob has buy milk!. Alice's edit inserts oat at 4, giving buy oat milk!. The copies differ, and nothing will ever fix them. Bob's edit needed to say 12 by the time it reached Alice, because 4 new characters had appeared before it.
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.
Decision What should each browser send when the user changes the document?
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.
// 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
];
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?
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.
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.
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.
- Inserts in different places. Text
buy milk. Alice insertsoatat 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 getbuy oat milk!. - Inserts at the same spot. Alice inserts
2at 4 and Bob insertsoatat 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 getbuy 2 oat milk. - Insert after a delete. Text
buy oat milk. Alice deletesbuy(4 characters at 0). Bob typessat 7, to makeoats. 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 getoats milk. - Overlapping deletes. Text
buy oat milk. Alice deletesoat(4 at 4). Bob deletesbuy 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 getmilk.
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.
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 }] : [];
}
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.
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.
| Concern | OT with a central server | Sequence CRDT (like Yjs) |
|---|---|---|
| Needs a server to order edits? | Yes, one per document. | No. Peers can sync in any order. |
| Offline for days | Works, but a long queue rebases against many ops on reconnect. | Natural: merge whenever copies meet. |
| Memory per character | None beyond the text. | Ids and tombstones; small with run merging, grows with history. |
| Where the hard code lives | Transform functions for every pair of op types. | Inside the library; the app uses its shared types. |
| Permissions and validation | The server sees and can reject every op. | Possible with a server relay, but peers can merge anything they receive. |
| Known users | Google Docs, ShareDB, CKEditor 5 collaboration. | Yjs (used with Tiptap and many editors), Automerge. |
Decision Which merging approach should this editor use?
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?
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'.
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.
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[];
};
From key press to pixels
- The user presses
e. The browser firesbeforeinputwithinputType: "insertText"anddata: "e". - The editor calls
preventDefault(), reads the selection from the model (not the DOM), and builds the op: inserteat 41. - The op is applied to the model, recorded for undo, and handed to the sync client. None of these wait for the network.
- 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.
- IME input is the exception. During composition (
compositionstarttocompositionend),insertCompositionTextevents 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
revand is waiting for the server's ack. Its local document isrevplus 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.
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 });
}
}
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.
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.
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?
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?
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.
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.
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?
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.
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
beforeinputevents withinputTypeofhistoryUndoandhistoryRedo, 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?
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.
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?
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.
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
- Notice. The socket closes, or a heartbeat gets no reply within 10 s.
navigator.onLineis only a hint: it can say online while nothing gets through. The save indicator switches to "Offline, changes saved on this device". - 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
visibilitychangeto hidden, which is the last event a mobile browser reliably fires. - 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.
- Say hello. Send
hellowith the document id, the client'srev, its client id, and theseqof the in-flight batch. The server replies with every op afterrev. - 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 sameonRemotepath, 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. - 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 repliestoo-far-behindwith 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.
| Part | Responsibility | Why it's separate |
|---|---|---|
| Input layer | Listens to beforeinput, keys, IME, paste, and turns them into commands. | Browser quirks stay in one place. |
| Editor core | The document model, commands that produce ops, and the undo manager. | Pure logic: no DOM, no network, easy to test with fuzzing. |
| Renderer | Draws visible pages from the model, restores the selection, draws the cursor overlay. | Can be swapped (DOM now, canvas later) without touching sync. |
| Sync client | The three-state protocol, transforms, acks, revision numbers, reconnects. | Network timing never leaks into the editor core. |
| Presence client | Throttled cursor updates, mapping remote cursors, heartbeats. | Ephemeral and lossy, unlike document ops. |
| Comments store | Threads and replies from REST and live events; anchors come from the model. | Different data, different lifetime, different API. |
| Local persistence | IndexedDB 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. |
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.
| Entity | Lives in | Why |
|---|---|---|
| Document model | Editor core memory, copied to IndexedDB at each ack | Edited in memory every keystroke; the copy makes offline opening possible. |
| In-flight and pending ops | Sync client memory and IndexedDB | Must survive a closed tab until acked. |
| Undo and redo stacks | Editor core memory only | Per tab and per session, like any editor; capped at 100 steps. |
| Presence | Presence client memory | Rebuilt from the server every few seconds; never persisted. |
| Comment threads | Comments store, fetched and cached per document | Server data with its own updates; anchors live in the model as marks. |
| Revision list | Fetched per page in the history view | Large and rarely read; never loaded in the editor. |
| Current revision in the URL | Only in history: ?rev=88120 | Shareable 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?
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.
// 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" };
wss://collab.example.com/d/:docId.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
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?
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.
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
beforeinputto 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
accessmessage 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
scripttag or anonerrorattribute has nowhere to live. Link marks accept onlyhttps,http, andmailtoURLs. - 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-motionby 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.
- 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.
- Minutes 5 to 12: the core problem. Draw two copies of
buy milkdiverging. Name ops as the unit of change, and walk through why last write wins, locks, and version checks fail for body text. - 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.
- Minutes 27 to 37: architecture and model. The parts table, the model as blocks and runs, model-first
contenteditable, and the message types. - 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.
- 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
contenteditableas the input surface fed bybeforeinput, 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.
References
- Operational transformation, Wikipedia The transformation properties, the history of OT systems, and links to the original papers.
- Collaborative Editing in ProseMirror, Marijn Haverbeke Why ProseMirror chose server-ordered steps with client rebase instead of classic OT.
- ProseMirror guide: collaborative editing The version-checked step protocol, in code.
- Yjs documentation A widely used sequence CRDT, with editor bindings, awareness (presence), and relative positions.
- About CRDTs, crdt.tech An overview of CRDTs with papers and implementations.
- 5000x faster CRDTs: An Adventure in Optimization, Joseph Gentle Where CRDT memory and speed costs come from, and how run merging fixes most of them.
- Input Events Level 2, W3C
beforeinputand everyinputType, including which ones can be cancelled. - The WebSocket API, MDN The transport for ops, acks, and presence.
- Using IndexedDB, MDN Storing the base document and the offline queue.
- Toolbar pattern, WAI-ARIA Authoring Practices Guide Keyboard behavior for the formatting toolbar.
Concepts behind it
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.
- Operational transformation (OT)A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.
- CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
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
startandendwith the revision they refer to. The server maps them forward through every op, like it transforms ops.Pros
Cons
A mark inside the document
Add a
commentmark 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
Cons
Quoted text with fuzzy matching
Store the quoted words and some surrounding text. Find the best match in the current document when rendering.
Pros
Cons
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.
Edge cases that decide the feel
POST /docs/:id/commentswith 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.Check your understanding · Exercise 9
A comment is anchored to
on Fridaywith a comment mark. Which outcomes are correct? Select all that apply.