cf.completefrontend
My progress
FUNDAMENTALS · MODULE 6

Real-time collaboration

How 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.

Why editing together is hard

In Google Docs, Figma, or Notion you can watch a colleague type a few words away from you while your own typing never stops. It looks simple. It is one of the hardest features to build on the web.

There is no single document on screen: every person has their own copy in their own browser tab. Changes travel between the copies over the network, and the network is slow compared with typing. Typical round trips are roughly 30 to 80 ms on good office Wi-Fi, 150 to 400 ms on a phone, and seconds on a train. While a change is in flight, everyone else keeps typing.

A two-person story that goes wrong

Asha and Ben share a document whose only line is big cat. Count the letters from 0: b is 0, the space is 3, and c is 4. At the same moment, Asha inserts black at position 4, so her copy reads big black cat. Ben deletes the 4 characters from position 0, so his copy reads cat.

Each editor sends its change to the other as an instruction. Asha's says "insert black at 4". Ben's says "delete 4 characters at 0".

Ben's editor receives Asha's instruction, but his copy is only 3 characters long. Position 4 doesn't exist, so a lenient editor puts the text at the end: catblack . Asha's editor applies Ben's delete to big black cat and gets black cat.

Asha and Ben both start with the text big cat. Asha inserts black and a space at position 4 and has big black cat. Ben deletes 4 characters at position 0 and has cat. Their edits cross in flight. Asha applies Ben's delete and gets black cat. Ben applies Asha's insert at position 4, which is past the end of his 3-letter text, so it lands at the end and he gets catblack. The two copies differ and nobody sees an error.AshaBenbig catbig catinsert at 4delete 4 at 0big black catcatblack catcatblackafter delete 4 at 0after insert at 4, past the endtwo different copies, and no error anywhere
Each editor applied the other's instruction exactly as written. Positions measured on the old text point at the wrong place in the new one, so the copies quietly drift apart.

Both editors did exactly what the instructions said. No error was thrown, and the two people now look at different documents without knowing it. Every later change will land in the wrong place.

The root cause: each instruction named a position measured on the text its author saw. The other person's edit moved the text underneath it. Any edit that says "at position N" can be broken by an edit its author didn't know about, and in a live editor, that happens many times a minute.

The three guarantees a collaborative editor must keep

Research on group editors in the 1990s named the properties a correct system needs. You will hear these words in interviews.

  • Convergence. Once everyone has received every change, all copies are identical. The story above breaks this.
  • Intention preservation. Each change does what its author meant. Asha meant "put black in front of cat". The right final text is black cat, with her word kept and Ben's deletion kept too.
  • Causality. If one change was made after seeing another, nobody sees them in the opposite order. If Ben answers Asha's comment, no one should see his answer before her comment.

Why not just lock?

The obvious fix is a lock: while Ben edits a paragraph, nobody else may. Some products do lock big objects, like a spreadsheet cell while someone types in it, or a slide in a presentation tool. For flowing text, locks fail in three ways.

  • People wait. Getting the lock costs a round trip before the first letter appears.
  • Locks get stuck. A laptop that sleeps holding a lock blocks everyone until a timeout.
  • Users don't expect them. Since Google Docs, everyone expects to type freely in the same paragraph.

So modern editors don't prevent concurrent edits. They let them happen and merge them. This lesson covers the architecture every such editor shares: local echo, a server that orders changes, presence kept apart from content, and snapshots with history. The merge algorithms get their own lessons: operational transformation (OT), which rewrites the positions in incoming edits, and CRDTs (conflict-free replicated data types), which give every character or object a stable id.

Check your understanding · Exercise 1

Which of these pairs of edits are concurrent? Select all that apply.

The mental model: copies, a referee, and a log

Picture five people reviewing a printed report in a meeting room. Everyone scribbles on their own copy right away, so nobody waits. One note-taker keeps the official copy.

People call out their changes, the note-taker writes each one down in the order heard, and reads it back to the room. When you hear a change that shifts the line you were working on, you fix your own copy to match.

The scribbling is local echo: you see your own change at once. The note-taker is the server, which decides one official order. The note-taker's list is the operation log. Fixing your copy to fit someone else's change is the merge, done by operational transformation or a CRDT.

Send changes, not whole documents

The first design choice hides in the word "change". A simple app saves by sending the whole document: PUT /docs/42 with 80 KB of text. If two people do that, the last request wins and the other person's work is gone. This is last writer wins, and for a shared document it means lost work.

Collaborative editors send operations instead: small descriptions of one change, like "insert black at 4" or "set the fill of shape 17 to blue". An operation is usually 20 to 200 bytes. Because each one describes only what its author touched, two operations on different parts of the document never overwrite each other, and two on the same part can be merged by looking at both.

ts
// A text editor: positions in a string
type TextOp =
  | { kind: "insert"; pos: number; text: string }
  | { kind: "delete"; pos: number; length: number };

// A design tool: properties on objects with ids
type PropOp = { kind: "set"; objectId: string; key: string; value: unknown };

// What travels over the wire
type Envelope = {
  clientId: string; // which tab sent it
  seq: number;      // that tab's own counter, for de-duplication
  baseRev: number;  // the server revision the author was looking at
  ops: TextOp[] | PropOp[];
};
Operations for a text editor and for a design tool, and the envelope that carries them to the server.

The document is the result of its log

Once changes are operations, the document stops being a thing you store and becomes a thing you compute. Start from an empty document, apply operation 1, then 2, then 3, and so on. If two copies start from the same state and apply the same operations in the same order, they must end up identical. That one sentence is how a central server gives convergence.

This also explains the other features. Version history is the log read at an earlier point. Undo is a new operation that reverses an old one.

A snapshot is a saved result of replaying the log up to some point, so you don't have to replay from the start every time. Each later chapter builds one of these on top of the log.

Architecture of a collaborative editor. Each browser tab holds an editor view, a local document that changes at once, and a pending queue of unconfirmed operations. The collaboration server for document 42 holds a presence map in memory, an ordering step that numbers operations as revisions, an operation log, and snapshots. Presence goes from the editor view to the presence map on a lossy path. Operations with their base revision go from the pending queue to the ordering step; acks and other people's operations come back to the local document.Each browser tabEditor viewpaints, takes keysLocal docchanges at oncePending queuelocal, in flightlocal loop: no networkCollab server, doc 42Presence mapmemory onlyOrderingrev 41, 42, 43Op logevery opSnapshotsevery 1,000presence, lossyacks + remote opsops + baseRev
Two paths leave every tab: lossy presence at the top, ordered operations at the bottom. Everything the server saves goes through one ordering step per document.

Two loops run at once

Inside each browser tab, two loops run side by side, and keeping them apart in your head makes the rest of the lesson easy.

  1. The local loop, every keystroke: read the input, build an operation, apply it to the local copy, re-render. It never touches the network and must finish in a few milliseconds.
  2. The sync loop, in the background: send pending operations to the server, receive confirmations and other people's operations, merge them into the local copy. It runs at network speed, tens to hundreds of milliseconds.

Bugs almost always come from where the two loops meet: an operation arrives while the user has local changes it doesn't know about. The next two chapters cover that meeting point from the client's side and the server's.

Check your understanding · Exercise 2

Why do collaborative editors send operations instead of the whole document? Select all that apply.

Local echo: your own edits never wait

Typing has to feel instant. Past about 100 ms, people notice the gap between pressing a key and seeing the letter, and good editors aim for one frame, about 16 ms.

A round trip to the server alone is 30 ms on a good day and 300 ms or more on a phone. So a collaborative editor can never wait for the server before showing a keystroke. It applies the operation to the local copy at once, paints, and only then sends it. This is local echo, sometimes called local-first echo: your screen shows the result before anyone else has agreed to it.

This is an optimistic update (showing a change before the server confirms it) applied to every keystroke, with one difference: a keystroke almost never rolls back. Instead, it gets merged with whatever other people did in the meantime.

Round trip to the server:

All changes saved. 0 keystrokes sent in 0 messages.

Two editor screens with a 300 ms round trip. The screen that paints only confirmed text shows "". The local echo screen shows "" at once. Its queue has 0 keystrokes in flight and 0 waiting. 0 keystrokes so far went out in 0 messages.Paints confirmed only|up to dateLocal echo|All changes savedin flight: 0waiting: 00 keys, 0 messageseach round trip takes 300 ms; the local screen waits 0 ms
On a slow link the left screen trails every keystroke, which feels broken. The right screen paints at once, and because only one batch is in flight, keys typed during a round trip ride together in the next message.

Every operation moves through three states

The client keeps its own operations in a pending queue, split into two parts. Following one operation from key press to confirmation shows why.

  1. Local (Google's posts call these pending changes): applied to the local copy, not yet sent. New keystrokes join this group, and the editor can combine them, so typing hello becomes one insert of five letters rather than five operations.
  2. In flight: sent to the server, waiting for its answer. Google's 2010 posts on Docs say the client never has more than one change in flight at a time. Everything typed meanwhile waits in the local group and goes out as the next batch.
  3. Confirmed: the server has accepted it, given it a revision number, and saved it. The client drops it from the queue. It is now part of the shared history.

Rarely, an operation is rejected: the user lost edit access, or it broke a rule like a size limit. The client undoes it locally and tells the user.

A state machine for one operation. It starts as Local, applied to the screen but not sent; more keystrokes join the same batch. Send moves it to In flight, waiting for the server. Acked moves it to Confirmed, saved with a revision number, and it leaves the queue. Rarely, the server rejects it, and it moves to Rejected: undo it locally and tell the user.Localon screenIn flightsent, no ackConfirmedrev 42, savedsendackedmore keys jointhe same batchrejectedRejectedundo it, tell the userleaves the queue
Most operations take the top row from left to right in one round trip. The rejected path is rare, but it has to exist: lost access or a broken limit.

When someone else's operation arrives

This is the meeting point from the last chapter. Ben's operation arrives from the server. It was made on top of the shared history, but Asha's copy also contains her pending operations, which Ben has never seen. Applying Ben's operation as it is would repeat the big cat bug.

So the client adjusts Ben's operation to fit on top of Asha's pending ones, then applies it. It also adjusts her pending operations to sit after Ben's, the order the server already chose. With OT this is a transform function on pairs of operations; with a CRDT, operations name characters by stable ids, so they slot in without adjusting. Either way, the user's own text doesn't move.

Keep the cursor and selection still

Local echo isn't only about the letters. If Ben inserts a sentence above Asha's cursor, every character after it shifts down. The editor must move Asha's cursor and selection by the same amount, or her next keystroke lands in the wrong place. Editors run the cursor through the same mapping as the text: a cursor at offset 120 becomes 160 after a 40-character insert before it.

Show sync status honestly

Because the screen runs ahead of the server, the user can't tell from the text alone whether their work is saved. Show a quiet status that follows the queue: "Saving..." while anything is pending, "All changes saved" when the queue is empty, and "Offline: changes will sync when you reconnect" when the socket is down. Never show a spinner per keystroke.

ts
window.addEventListener("beforeunload", (event) => {
  if (pendingQueue.length > 0) {
    // Browsers show their own generic text; the message is ignored.
    event.preventDefault();
  }
});
Warn before closing the tab while edits are still pending.

Check your understanding · Exercise 3

Asha has typed 12 letters that are still pending when Ben's insert arrives from the server. What must her editor do? Select all that apply.

A central server puts every operation in one order

Most collaborative products, including Google Docs, Figma, and Notion, send every operation through a server they control. The server's main job is to be the referee: it decides the one official order in which operations happened. Everything else in the design rests on that order.

Revisions: one counter per document

The server keeps a counter for each document called the revision. Every operation it accepts gets the next number: revision 41, 42, 43. The revision is the document's clock. "The document at revision 42" means one exact text that every client can rebuild.

Each client remembers the last revision it has seen, and sends its operations with that number as baseRev: "I made this looking at revision 41". The server compares it with its own head revision. If they match, the operation fits as it is. If the server is already at 43, the operation missed two others and must be adjusted against them, in the same way the client adjusts incoming operations, before it is accepted.

One server, one orderStep 1 of 5: Two edits at revision 41

A sequence diagram with Asha, the server, and Ben. Both edit big cat at revision 41. Asha's insert reaches the server first and becomes revision 42; Ben's delete is adjusted and becomes revision 43. Acks go to each author and each operation is broadcast to the other. At the end all three copies read black cat.AshaServerBenbig black cat41: big catcat
Both start from big cat at revision 41. Asha inserts black and Ben deletes big . Each sees their own change at once: local echo.

Two edits at revision 41: Both start from big cat at revision 41. Asha inserts black and Ben deletes big . Each sees their own change at once: local echo.

A worked example, message by message

Back to big cat, now at revision 41, with Asha and Ben both connected. Here is what actually crosses the wire.

json
// Asha -> server
{ "type": "ops", "clientId": "asha-7f", "seq": 18, "baseRev": 41,
  "ops": [{ "kind": "insert", "pos": 4, "text": "black " }] }

// Ben -> server
{ "type": "ops", "clientId": "ben-c2", "seq": 5, "baseRev": 41,
  "ops": [{ "kind": "delete", "pos": 0, "length": 4 }] }

// server -> Asha (her op is now revision 42)
{ "type": "ack", "seq": 18, "rev": 42 }
// server -> Ben (Asha's op, as revision 42)
{ "type": "remote", "rev": 42, "clientId": "asha-7f",
  "ops": [{ "kind": "insert", "pos": 4, "text": "black " }] }

// Ben's op had baseRev 41 but head is 42: the server shifts it past
// Asha's insert. Deleting at 0 is before her insert, so it stays the same.
// server -> Ben
{ "type": "ack", "seq": 5, "rev": 43 }
// server -> Asha
{ "type": "remote", "rev": 43, "clientId": "ben-c2",
  "ops": [{ "kind": "delete", "pos": 0, "length": 4 }] }
Both clients send at almost the same moment. Asha's message reaches the server first.

Asha applies Ben's delete to big black cat and gets black cat.

Ben's client receives Asha's insert while his delete is still in flight. It adjusts her insert to fit his pending delete: she inserted at 4, but 4 characters before that are gone in his copy, so the insert moves to 0. He gets black cat too. Both copies converge, and both intentions survive.

The operation log for one document as a table with one column per revision, 40 to 43. Revision 40: Chen deleted 1 character at 8, based on 39. Revision 41: Chen deleted 1 character at 7, based on 40, leaving big cat. Revision 42: Asha's insert at 4, based on 41, from asha-7f sequence 18. Revision 43: Ben's delete of 4 at 0, also based on 41, from ben-c2 sequence 5. Revision 43 was adjusted past 42 before it was saved. The client and sequence pair lets the server spot a resend.rev 40rev 41rev 42rev 43authorChenChenAshaBenopdel 1 at 8del 1 at 7ins at 4del 4 at 0baseRev39404141client:seqchen-1a:7chen-1a:8asha-7f:18ben-c2:543 was made on 41, so it was adjustedpast 42 before the server saved it.
The log is append-only and the revision is the column number. A baseRev lower than the revision just before it means the operation was adjusted; client:seq is how resends are spotted.

What the server does with each operation

  1. Check it. Is this user allowed to edit this document? Is the operation well formed and within limits, such as a 1 MB paste cap?
  2. De-duplicate it. If clientId plus seq is already in the log, this is a resend after a reconnect. Reply with the original revision and do nothing else.
  3. Adjust it. If baseRev is behind the head, transform it against every operation since baseRev.
  4. Save it. Append it to the operation log in durable storage with the next revision number.
  5. Confirm and broadcast. Send an ack to the author and a remote message to everyone else in the document.

Step 4 must come before step 5. If the server confirms and then crashes before writing, the author believes the edit is saved and it is gone. Saving first costs a few milliseconds per batch, one reason clients batch keystrokes.

One place per document

Ordering only works if one process decides the order for a given document. If Asha's socket lands on server A and Ben's on server B, and both servers accept operations, there are two referees and two orders. So all clients of a document are routed to the same process: the load balancer or a routing table maps the document id to one server, and a busy server holds thousands of documents in memory, each with its own queue.

If that process crashes, another one takes the document over, loads the latest snapshot and log from storage, and clients reconnect to it, as chapter 8 shows.

Check your understanding · Exercise 4

Why must the server write an operation to storage before sending the ack? Select all that apply.

Presence is not document state

A collaborative app sends two very different kinds of data, and the most common design mistake is to treat them the same way. Look at any shared document and you can split what you see in two.

  • Document state is the content: the text, the shapes, the cells, the comments. Every change matters, changes must be applied in order, and all of it is saved forever (or until someone deletes it).
  • Presence is who is here right now and what they are doing: avatars in the top bar, a colored cursor with a name tag, someone's selection highlighted, "Ben is typing". It only matters for a moment. Yjs calls this awareness; other tools say ephemeral state.
A wireframe of a shared document called Q3 plan. At the top right are three avatars: A, B, and C. Five lines of text fill the page. Ben's selection is highlighted across part of the second line, with his name above it. Asha's cursor sits in the fourth line, with her name next to it. At the bottom, Chen is typing. Notes on the right say: presence is the avatars, cursors, selection, and typing, which are lossy and kept in memory; the document is the text itself, ordered and saved; if an update is lost, presence ignores it and the document resends it.Q3 plan (shared)ABCBenAshaChen is typing...presence: avatars,cursors, selection,typing; in memorydocument: thetext itself,ordered and savedupdate lost?presence: ignoredocument: resend
Everything highlighted is presence: it changes many times a second and is never saved. Only the plain lines of text are document state.

Opposite rules for the two kinds

Presence changes far more often than the document. A moving mouse produces 60 or more events a second. Losing one cursor update hurts nobody, because the next one, 50 to 100 ms later, replaces it. Losing one text operation corrupts the document, so the two need opposite treatment.

QuestionDocument statePresence
ExamplesText, shapes, cells, commentsCursors, selections, who is online, typing indicator
If a message is lostResend it until it is ackedIgnore it; the next update replaces it
OrderingStrict, one order set by the serverOnly the latest value per person matters
Saved to the databaseYes, with historyNever; kept in server memory only
Typical rate per personA few batches per second while typing10 to 20 updates per second while moving
When the person leavesTheir edits stayTheir presence is removed within seconds

How presence flows

Presence usually rides on the same WebSocket as operations, as its own message type, but it skips the whole ordering pipeline: no revision number, no log, no ack. The server keeps a small in-memory map per document, from connection id to the latest presence value, and relays each update to the other clients.

json
{ "type": "presence", "clientId": "ben-c2",
  "user": { "name": "Ben", "color": "#3b82f6" },
  "cursor": { "anchor": 212, "head": 230 },
  "typing": true, "ts": 1792405986634 }
A presence update. It carries the whole current value, so any one message is enough to draw the cursor.
  • Send the whole value, not a change. Because messages can be dropped, each presence message replaces the last instead of patching it.
  • Throttle on the sender. Send at most one update every 50 to 100 ms, and only if something changed. Other clients animate the cursor between updates so it still looks smooth.
  • Expire on silence. Clients send a heartbeat every few seconds. If the server hears nothing for about 30 seconds, or the socket closes, it removes that person and tells the others.
  • Anchor cursors to the content. A cursor at offset 212 is wrong once someone inserts text above it. Clients map remote cursors through incoming operations, or send positions as stable ids that a CRDT provides.

Why mixing them hurts

Imagine putting cursor moves into the operation log. Ten people with a cursor update every 100 ms add 100 entries a second to a log that otherwise grows by a few. History fills with noise, snapshots come more often, opening the document replays millions of old cursor positions, and every cursor move waits behind a database write. The opposite mistake is just as bad: sending text through the lossy presence path means dropped letters.

Presence also has privacy rules: a cursor reveals what someone is reading. Products often let viewers be anonymous and never store presence, so there is no record of who read which paragraph when.

Check your understanding · Exercise 5

Which of these belong in presence rather than in document state? Select all that apply.

Snapshots and history

If the document is the result of its log, opening it means replaying the log. For 300 operations that takes a millisecond. A design doc edited by a team for two years can have well over a million, which could mean tens of megabytes to download and seconds of replay on every open.

What a snapshot is

A snapshot is the full document saved at one revision: "here is the text as of revision 1,200,000". The server takes one every so often, for example every 1,000 operations or every few minutes of activity, usually in the background so writes don't wait for it.

Opening a document then becomes two reads: the newest snapshot, and only the operations after it. The client builds its copy from the snapshot, replays the short tail, and joins the live stream at the head revision. However old the document gets, the tail stays short, so opening time depends on the document's size, not on its age.

A bar chart of the bytes downloaded to open a document at revision 1,200,340, drawn to scale, assuming about 50 bytes per operation and a 400 KB snapshot. Replaying the whole log: about 60 MB. A snapshot every 10,000 operations: the snapshot plus up to 9,999 operations, under 1 MB. A snapshot every 1,000 operations: the snapshot plus 340 operations, about 0.42 MB.bytes to open one old document, to scalereplay the whole log1,200,340 ops, about 60 MBsnapshot every 10,000 opssnapshot + up to 9,999 opssnapshot every 1,000 opssnapshot + 340 ops, 0.42 MB
Without snapshots, opening cost grows with the document's age. With them, it depends only on the document's size plus a short tail. Illustrative numbers: about 50 bytes per operation and a 400 KB snapshot.

Opening a document, step by step

  1. The page loads and opens a WebSocket for document 42, or fetches over HTTP first so the content can render before the socket is ready.
  2. The server sends the newest snapshot (revision 1,200,000) and the 340 operations after it, up to the head at 1,200,340.
  3. The client builds the document and renders it. The user can read and type now.
  4. The client subscribes to new operations from revision 1,200,340 onwards, and starts sending presence.

Version history comes almost for free

Because every operation is saved with its author, time, and revision, the log already is the history. To show the document as it was last Tuesday at 15:00, find the newest snapshot before that time and replay operations up to it. Try it in the lab below: the further a revision is from a snapshot, the more operations the server replays.

Snapshot every:
A timeline of revisions from 0 to the head at 5830, with a snapshot every 1000 operations, 5 snapshots in all. Opening revision 4210 loads the snapshot at 4000 and replays 210 operations. Storing the snapshots takes about 200 KB. The last change before this revision: named version: Sent to legal.open revision 4,210= snapshot 4,000 + replay 210 opsrev 0head 5,8305 snapshots stored, about 200 KBlast change before it: named version: Sent to legal
More snapshots mean less replay on every open and every history view, at the cost of storage. Real products also keep fewer snapshots for old history.
  • Group for people. Nobody wants to browse 40,000 keystrokes. History screens group operations by author and by gaps in time, such as "Asha, 14:02 to 14:31".
  • Named versions like "Sent to legal" are just a label pinned to a revision, often with a snapshot so they open fast.
  • Restoring is a new edit. Going back to Tuesday doesn't delete the log after it. The server computes the difference and appends it as new operations, so the restore itself shows up in history and can be undone.

Undo is personal

In a shared document, Ctrl+Z must undo your own last change, not Ben's, even if his came later. So undo can't be "step the log back one".

Instead, the client keeps a stack of its own operations. To undo one, it builds the inverse (delete what you inserted, put back what you deleted), adjusts it for everything that happened since, and sends it as a normal new operation. Redo works the same way in reverse.

Keeping the log from growing forever

A log of single keystrokes for a busy document grows by megabytes a week. Products compact old history: keep every operation for recent days, then only periodic snapshots, such as hourly and later daily, and drop the keystrokes between them.

Check your understanding · Exercise 6

A document has a snapshot every 1,000 operations and is at revision 52,430. What does a client download to open it? Pick one.

Choosing how to merge

Every collaborative product has local echo, an ordered path for content, a lossy path for presence, and snapshots. Where products differ is how they merge concurrent changes to the same thing. That choice depends mostly on what the document is made of: a long string of characters, or a tree of objects with properties.

Text versus objects

Text is the hard case. Characters have only positions, every insert shifts every later position, and people often type in the same sentence.

Objects are easier. A shape has a stable id like shape:17 and properties like x and fill. Changes to different shapes or different properties never conflict.

For the same property at the same moment, a simple rule works: the server's order decides and the later value wins. Figma described building multiplayer this way rather than with OT.

Decision How should concurrent changes to the same document be merged?

Lock while editing

Before editing an object, ask the server for a lock.

Others see it as read-only until it is released or times out.

Pros

  • Simplest to build and reason about.
  • No merge logic at all.

Cons

  • A round trip before the first change.
  • Useless for text that people type in together.

Server ordering with OT

Operations name positions.

Server and clients transform concurrent operations against each other.

The server's order is final.

Pros

  • Small operations and small documents in memory.
  • Proven at huge scale by Google Docs.
  • History is a plain list of operations.

Cons

  • Transform functions are hard to get right for rich text and tables.
  • Long offline sessions merge poorly.

CRDT, relayed by a server

Every character or object gets a stable id.

Changes merge in any order without transforming.

A server relays and stores updates, but doesn't have to decide the order.

Pros

  • Works offline for as long as needed, and can sync peer to peer.
  • Ready-made libraries: Yjs and Automerge.

Cons

  • Extra memory for ids and deleted items.
  • Harder to enforce server-side rules on content.
  • History and undo need care.

Per-property last writer wins

The document is a tree of objects with ids.

Each property change is sent to the server.

For the same property, the server's later order wins.

Pros

  • Very simple, fits design tools and whiteboards.
  • Easy server-side checks.

Cons

  • Doesn't work for text inside an object: two people typing in one text box overwrite each other.
  • One person's change can silently replace another's.

What we pick: For a rich text editor with a server you control, use server ordering with OT, or a CRDT library such as Yjs relayed through your server: both are proven, and the CRDT path is far less code. For a canvas or whiteboard, use per-property last-writer-wins on objects, and a text CRDT only inside text boxes. Choose a CRDT when offline editing for hours or peer-to-peer sync is a real requirement. Use locks only for coarse objects where waiting is acceptable.

A decision tree. What is edited? If shared text, ask: must it work offline for hours, or peer to peer? Yes: a CRDT such as Yjs or Automerge. No: server ordering with OT, or a CRDT relayed by the server. If objects with properties, ask: is there text inside the objects? Yes: mixed, last-writer-wins per property plus a text CRDT inside text boxes. No: last-writer-wins per property in server order. Locks fit only coarse objects where waiting is fine.What is edited?shared textobjectsOffline for hours?or peer to peerText inside objects?like sticky notesyesnoyesnoCRDTYjs, AutomergeOT or CRDTserver orderedMixedLWW + CRDTLWW per propserver orderlocks: only for coarse objects where waiting is fine
Start from the data, not the algorithm. Text that people type in together needs real merging; properties on objects mostly don't.

Is a central server required?

With OT, yes in practice: the server's single order keeps the transform logic simple enough to be correct. With CRDTs, no in theory: copies can exchange updates directly, over WebRTC (the browser's peer-to-peer connection API) for example, and still converge.

In practice nearly every CRDT product still runs a server to store documents, check permissions, and catch up late joiners. The question is only how much the server decides. The next lessons, Operational transformation and CRDTs, show how each one merges, and Conflict resolution strategies covers object-level rules.

Check your understanding · Exercise 7

You're designing a whiteboard with sticky notes. People drag notes and type short text on them. Which designs make sense? Select all that apply.

What goes wrong in production

A demo with two tabs on one laptop works on the first try. Real users have flaky Wi-Fi, sleeping laptops, tabs open for a week, and documents with 200 viewers.

Dropped connections and offline edits

Connections drop all the time: a tunnel, a Wi-Fi handoff, a laptop lid. While the socket is down, local echo carries on, and new operations pile up in the local part of the queue. The status shows "Offline" so nobody is surprised.

On reconnect, the client says which revision it has. The server sends every operation since then. If the server saved the in-flight batch just before the drop, that list includes it under its new revision. The client spots its own clientId and seq and treats that entry as the missing ack, not as someone else's edit.

The client merges the other operations with its pending queue, exactly as in chapter 3, and sends what is still pending with the original clientId and seq. If a resent operation was already saved, the server recognizes the pair and acks it again without applying it twice. A two-second outage costs the user nothing.

A sequence diagram between Asha's tab and the server. Asha sends seq 18 based on revision 57, then the socket drops. While offline, seq 19 and 20 stay queued. On reconnect she sends hello with last revision 57. The server replies with operations 58 to 63 and says her seq 18 was saved as revision 60. Her tab rebases 19 and 20 on top of 63 and sends them with base 63. The server acks them as revisions 64 and 65.Asha's tabServerseq 18, base 57socket dropsoffline: seq 19, 20 stay queuedhello: last rev 57ops 58 to 63; seq 18 is rev 60rebase 19, 20 on top of 63seq 19, 20, base 63ack: rev 64, rev 65
The pair clientId plus seq lets the server tell a resend from a new edit, so an operation saved just before the drop is never applied twice.

Long outages are harder. Two people who each rewrite the same paragraph for an hour will converge, but into text that may not read well, because both rewrites survive side by side. Products choose differently: CRDT-based apps such as those built on Yjs merge anything, while some editors make the document read-only after a few minutes offline, or show a "your offline changes" review step.

The failures teams meet, and their fixes

FailureHow you noticeFix
Duplicate operation after a resendDoubled text, like hellohelloServer de-duplicates on clientId plus seq; resends keep their ids
Copies silently diverge from a bugUsers report "I see different text"Send a checksum of the document every few hundred revisions; on mismatch, reload from a snapshot and report the bug
Tab open for days, its revision long compactedServer can't send the missing operationsServer replies "too old"; client loads a fresh snapshot and re-applies its pending operations on top
Old client code after a deployNew operation types the old editor can't applyA protocol version in the handshake; the server asks outdated clients to reload
Edit access removed mid-sessionWrites keep arriving from a viewerServer checks access on every batch, rejects, and the client switches to read-only
One huge paste, like 5 MB of textSocket stalls, everyone lagsSize limit per operation; very large inserts upload over HTTP and enter the log as one reference
A document with 500 people openBroadcast cost grows with every viewer, cursors flood the screenBatch outgoing broadcasts every 50 ms, cap rendered cursors, and stop sending presence for viewers who aren't active
Server process for a document crashesAll its sockets close at onceAnother process loads snapshot plus log; clients reconnect with jittered backoff so they don't all arrive in the same second

The reconnect stampede

The last row deserves a closer look. A server holding 5,000 open documents restarts during a deploy. 40,000 clients see their sockets close in the same second and all reconnect at once, each asking for a snapshot. The new server falls over under that load, and the cycle repeats.

The fix is on the client: wait a random delay before reconnecting, such as 0 to 2 seconds the first time, then double the window on each failure up to about 30 seconds. Spread across 40,000 clients, a 2-second window turns one spike into a gentle slope. Deploys should also drain documents a few at a time rather than all at once.

Check your understanding · Exercise 8

Asha's laptop sleeps for 20 minutes with 3 unsent operations. The server compacted the log, so it no longer has the operations after her revision. What should happen? Select all that apply.

In real products, and the performance, security, and accessibility angles

How well-known products do it

  • Google Docs uses operational transformation with a central server. Google's 2010 posts on the rewrite describe this lesson's model: a revision log of operations, pending and sent changes on each client, and never more than one unacknowledged change sent at a time.
  • Figma described its multiplayer system in a 2019 engineering post. The server is the authority for each file, the file is a tree of objects with properties, and conflicts on the same property are settled by last-writer-wins. While a local change is unacknowledged, the client ignores incoming server values for that property, so the user's own change doesn't flicker back.
  • ProseMirror ships a prosemirror-collab module with a central authority and version numbers. Its server rejects changes based on an old version; the client rebases them on the newer ones and tries again.
  • Yjs is a widely used CRDT library with bindings for ProseMirror, Tiptap, CodeMirror, Monaco, and others. Its WebSocket provider relays binary updates through a server, and its separate awareness protocol carries presence, which is exactly the split from chapter 5.

Four different merge choices (transform, reject and retry, later value wins, data structure merges) and the same outer architecture every time.

Performance

  • Batch outgoing operations. Collect keystrokes for one in-flight round trip, or for 50 to 100 ms, and send them as one message. Fewer messages, fewer database writes, and smaller history.
  • Keep messages small. JSON is fine at first. Libraries like Yjs encode updates in a compact binary format, which matters for documents with many editors.
  • Render only what changed. An incoming operation should update one paragraph, not re-render a 200-page document. For very long documents, render only the visible part.

Security

  • Authenticate the socket. Check the session when the WebSocket opens, and check the Origin header too: browsers can send cookies with cross-origin WebSocket handshakes, so without the check another site could open a socket as your user.
  • Authorize every batch, not just the connection. Access can be removed while a socket stays open for hours.
  • Never trust the client's operations. Validate shape and size, enforce limits, and sanitize rich text the same way you would for a normal form, since an operation can carry any markup.
  • Stamp identity on the server. The name and avatar in a presence message, and the author on each operation, come from the session, not from what the client claims.
  • Rate limit. A buggy or hostile client can send thousands of operations per second. Cap per connection and disconnect offenders.

Accessibility

Remote cursors are purely visual, and a document that changes on its own can disorient screen reader users. A few rules help.

  • Never move the user's focus or caret because of a remote change. Map the caret through the change instead, as in chapter 3.
  • Announce people, not keystrokes. A polite live region (aria-live="polite") can say "Ben joined" or "Ben left". Announcing every remote edit would drown out the user's own reading.
  • Don't rely on color alone. Pair each cursor color with a name label, and pick colors with enough contrast against the selection highlight.
  • Respect reduced motion. Smoothly animated remote cursors are nice, but with prefers-reduced-motion: reduce, jump them instead.

Check your understanding · Exercise 9

Which checks belong on the collaboration server? Select all that apply.

Using it in a system design interview

Real-time collaboration comes up in two ways. Sometimes it is the whole question: "Design Google Docs", "Design a shared whiteboard", "Design Figma's multiplayer". More often it is one requirement inside a bigger design: live comments on a task board, a shared shopping list, a pair-programming view.

The Google Docs guide in this section's practice problems walks through the full product version. This lesson gives you the parts to build it from.

In both cases, the interviewer is checking whether you know why it is hard. Candidates who jump to "I'd use WebSockets" have answered the transport question and skipped the real one. Lead with the problem: copies on every device, edits that cross in flight, and the need for one order.

What to draw

  1. Two browsers, each with three boxes: editor view, local document, pending queue.
  2. One collaboration server for the document, with an ordering step, an operation log, and a snapshot store.
  3. Two arrows between each browser and the server, labelled "operations (ordered, saved)" and "presence (lossy, memory)".
  4. An HTTP path for the first load: snapshot plus tail, before joining the live stream.

Then walk one keystroke through it: applied locally, queued, sent with baseRev, ordered and saved by the server, acked, broadcast, merged on the other client. If you can tell that story with real revision numbers, most follow-up questions answer themselves.

How deep to go at each level

LevelWhat a strong answer covers
JuniorWhy sending the whole document loses work. Operations over a WebSocket. Show my own typing at once. A sync status indicator.
Mid-levelPending queue and acks. Revision numbers and a server that orders operations. Presence on a separate, throttled channel. Snapshots for fast loads. Reconnect with resend and de-duplication.
SeniorOT versus CRDT versus per-property last-writer-wins, chosen from the data model. One process per document and routing by document id. Persist before ack. Reconnect storms, version skew, divergence checks, compaction, security on the socket, and accessibility of remote changes.

Common mistakes

  • Saving the whole document on every change, which makes the last save win and silently drops other people's work.
  • Waiting for the server before showing a keystroke.
  • Putting cursor moves into the saved operation log.
  • Using timestamps from users' devices to decide order. Device clocks drift by seconds; the server's revision counter is the clock.
  • Explaining OT's transform rules in detail for 15 minutes and running out of time for loading, presence, and offline.

Check your understanding · Exercise 10

An interviewer asks: "Why not order edits by the timestamp each client attaches?" Which points make a strong answer? Select all that apply.

Summary

  • Every person edits their own copy. Edits cross in flight, and an edit that names a position can be broken by one its author never saw. The goals are convergence, intention preservation, and causality.
  • Send small operations, not whole documents. The document is the result of replaying its operation log.
  • Local echo: apply your own edits at once, keep them in a pending queue until acked, and fit incoming edits around them without moving the user's text or cursor.
  • A central server gives one order: it numbers each operation with a revision, adjusts stale ones, saves before it acks, and broadcasts. All clients of a document must reach the same process.
  • Presence is not document state: cursors and selections are lossy, throttled, in memory, and expire; content is ordered, acked, and saved.
  • Snapshots plus a short tail keep opening fast. The log gives history, named versions, restore, and per-person undo; compact old keystrokes.
  • Pick the merge strategy from the data model: OT or a text CRDT for shared text, per-property last-writer-wins for objects, locks only for coarse objects.
  • Plan for reconnects with resend and de-duplication, reconnect storms with jittered backoff, version skew, divergence checks, and a socket that checks Origin and permissions.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How do you make typing feel instant in a collaborative editor when the server is 200 ms away?

Question 2

Walk me through what happens when two people edit the same sentence at the same moment.

Question 3

Why keep presence separate from document operations?

Question 4

A document has been edited for three years. How do you keep it fast to open, and how do you show version history?

Question 5

When would you pick a CRDT over a central server with OT?

Practice it

These design problems lean on real-time collaboration.

References

Read next