Topic 25.7
Design a Collaborative Editor or Whiteboard
In one line
In a collaborative editor several people change one document at once, so clients send small operations instead of whole snapshots, and the system needs a rule (server ordering with OT, or CRDTs) that makes every screen end up the same, plus presence cursors, per-user undo and continuous autosave.
Think of it like this
Two friends colouring one big poster from opposite sides. If each of them took a photo of the poster, coloured their photo and then pasted it over the real poster, the second paste would wipe out the first friend's work. Instead, each one says what they did ("I coloured the sun yellow", "I drew a tree at the left") and both apply each other's changes. That's operations instead of snapshots, and it's how Google Docs, Figma and Miro let people work together.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Snapshot
- The whole document at one moment.
- Operation (op)
- One small change, like "insert
!at 5" or "set colour to teal". - Convergence
- Every client ends up with exactly the same document after all ops arrive.
- Lost update
- A change silently erased because someone else saved an older copy over it.
- Last-writer-wins (LWW)
- When two changes hit the same thing, the one ordered last is kept.
- OT
- Operational transformation: adjusting an op (like its position) to account for ops that happened at the same time.
- CRDT
- A data structure whose updates can be merged in any order and still give the same result.
- Tombstone
- A hidden marker left where something was deleted, so late ops that refer to it still make sense.
- Presence
- Live information about who's in the document and where their cursor is.
Step by step
01Requirements and the first big question
Text or canvas? Text needs OT or a CRDT for positions; a canvas of shapes can often use per-property last-writer-wins. Then ask about scale (editors per doc), offline (minutes or days), history and permissions. Numbers: remote edits visible within 300 ms, local edits instant, 50 editors, autosave every 100 ms batch.
02Architecture
Each client keeps a local copy of the document and applies its own ops at once. A sync layer sends ops to a collaboration server over a WebSocket and applies incoming ops. Presence travels on the same socket but is never stored. The server orders ops (for OT) or relays updates (for CRDTs), stores an op log and periodic snapshots.
03Component tree
The document model is outside React; components subscribe to the parts they draw, so a remote edit to page 40 doesn't re-render page 1.
04Data model
Whiteboard shapes are entities by id with properties. Ops name the entity and property. Each op carries who made it (for undo and attribution) and a client id (for de-duplication).
type Shape = { id: string; kind: "rect" | "note" | "arrow"; x: number; y: number; w: number; h: number; color: string; text?: string; z: string };
type Board = { id: string; shapes: Record<string, Shape>; version: number };
type Op =
| { kind: "create"; shape: Shape }
| { kind: "set"; id: string; key: keyof Shape; value: unknown }
| { kind: "delete"; id: string };
type Envelope = { opId: string; userId: string; baseVersion: number; ops: Op[] }; // a batch
type Presence = { userId: string; name: string; color: string; cursor?: { x: number; y: number }; selection?: string[] };05Interface: messages
Clients send batches of ops with the version they were based on; the server answers with an ack and the assigned version, and broadcasts ops to others. Loading uses a snapshot plus the ops after it.
// client -> server
{ type: "ops", opId: "a-118", baseVersion: 341, ops: Op[] }
{ type: "presence", cursor: { x: 420, y: 96 }, selection: ["s1"] } // throttled, ~20/s
// server -> client
{ type: "ack", opId: "a-118", version: 343 }
{ type: "ops", userId: "u2", version: 342, ops: Op[] } // others' changes, in server order
{ type: "presence", userId: "u2", cursor: { x: 80, y: 40 } }
// HTTP
GET /api/boards/b1 -> { snapshot: Board, opsSince: Envelope[] }
GET /api/boards/b1/versions -> [{ version: 300, at: "2024-01-15T10:30:00Z", by: "Asha" }]06Deep dive: two edits at once
Both clients apply their own op instantly, send it, then apply the other's op as ordered (and, for text, transformed) by the server. Both end in the same state.
07Deep dive: undo, autosave and performance
Undo keeps a stack of your ops with their inverses; undoing applies the inverse (transformed against later ops) as a new op, so it syncs like any edit. Autosave is the op stream itself plus a local queue in IndexedDB; the status pill reads from the sync layer. For performance, render the canvas with <canvas> or WebGL when shapes run into thousands, keep DOM-based shapes memoised by id, and throttle cursor rendering with requestAnimationFrame.
Try it yourself
- 1
Reverse the order
In the whiteboard example, call
bySnapshots(start, [ravi, asha]). Predict which edit is lost now. Does the operations result change if you reverse its order too? - 2
Break the tie-breaker
In the text example, change the tie rule to
against.pos === op.pos(without the user comparison). Predict the two "same position" results. Do Asha and Ravi still agree? - 3
Add a delete
Think it through before coding: Ravi deletes the character at position 2 of
Hellowhile Asha inserts!at 5. How must Asha's position change? Write atransformrule for insert-against-delete.
Code & diagrams
With snapshots the shape is teal but back at x=10: Ravi's upload never contained Asha's move. With per-property operations both changes survive. When both change the colour, the server's order picks one and every screen agrees. The dots are presence cursors: drawn, never saved.
Preview
Console
Nothing logged yet.Expected console output
snapshots: {"s1":{"x":10,"color":"teal"}} (Asha's move is lost)
operations: {"s1":{"x":120,"color":"teal"}}
same property: Asha sees teal, Ravi sees tealThis is only the insert-against-insert case of OT, enough to see the idea. Real OT also transforms deletes, formatting and selections, and real systems use a tested library.
Preview
Console
Nothing logged yet.Expected console output
naive on Ravi: Hey H!ello
OT on Asha: Hey Hello! | OT on Ravi: Hey Hello!
same position: ARcat | ARcat// Sketch of the shape of the code with Yjs (check the library docs for exact APIs and providers).
import * as Y from "yjs";
const ydoc = new Y.Doc();
const shapes = ydoc.getMap("shapes"); // shared map: id -> shape data
// A provider (WebSocket, WebRTC or a hosted service) syncs ydoc with other clients.
function useShapes() {
return useSyncExternalStore(
(onChange) => { shapes.observeDeep(onChange); return () => shapes.unobserveDeep(onChange); },
() => snapshotOf(shapes), // must return the SAME object until something changes
);
}
function moveShape(id: string, x: number) {
ydoc.transact(() => {
const s = shapes.get(id) as Y.Map<unknown>;
s.set("x", x); // a small update, merged with everyone else's
});
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Drop the tie-breaker
In the text example, change before to const before = against.pos <= op.pos; (both sides treat the other's insert at the same position as coming first).
Myth vs fact
Myth
Saving the whole document often enough prevents conflicts.
Fact
Whole-document saves are the conflict: the later save erases concurrent edits it didn't contain. Send operations, or at least merge on the server.
Myth
CRDTs mean you don't need a server.
Fact
CRDTs don't need a central authority to merge, but you still need servers to relay updates, store documents, check permissions and keep history.
Myth
Undo in a shared document is the same as in a single-user app.
Fact
Undo must reverse only your own changes, as a new operation transformed against everything others did since. Popping the last state would erase other people's work.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
OT's correctness rests on transformation properties that are notoriously hard to get right for every op pair (insert, delete, format, split, merge). That's why production OT systems keep a single server ordering ops, and why many teams pick CRDT libraries instead.
- ▸
CRDT documents grow: ids per character and tombstones cost memory. Libraries compact and encode updates efficiently, but very long-lived documents still need snapshots and garbage collection strategies; measure with real documents.
- ▸
Intent matters: if Asha bolds a word while Ravi deletes half of it, what should happen? Algorithms define *a* consistent answer; product design decides whether it's the right one. Interviewers like hearing that convergence isn't the same as "what the user meant".
- ▸
Permissions are enforced on the server per op (can this user edit this block?), not only by hiding buttons. A read-only viewer must not be able to send ops through the socket (Phase 21.5).
Remember this
- 1
Requirements. Functional: several users edit one document (rich text) or board (shapes, sticky notes) at the same time; everyone sees others' changes within about 100–300 ms; live cursors and selections with names (presence); undo and redo of *your own* changes; version history; works through short network drops and keeps local edits. Non-functional: convergence (every client ends with the same document), no lost edits, typing never waits for the network (local edits apply instantly), documents of 100+ pages or thousands of shapes stay smooth, up to ~50 simultaneous editors per document, autosave with no Save button. Clarify: text or canvas? How many concurrent editors? Offline editing for hours, or just short drops? Comments and permissions?
- 2
Operations versus snapshots. A snapshot is the whole document at a moment. If clients send snapshots and the server keeps the last one, concurrent edits are lost: Ravi's snapshot doesn't contain Asha's change, so it erases it (the lost update problem). An operation (op) is one small change: "set shape s1's x to 120", "insert
!at position 5". Ops are small to send, can be merged with other people's ops, form a history you can replay (version history, undo), and can be batched (send every 50–100 ms). Servers still store snapshots now and then so loading a document doesn't replay a million ops (compaction). - 3
Conflicts on properties: last-writer-wins. For a whiteboard, many conflicts are about properties (Asha moves a shape while Ravi recolours it). If ops target single properties, both survive. When two people change the same property, a rule must pick one: last-writer-wins (LWW) by the order the server received them is simple and, because every client applies the server's order, everyone converges. Figma has described using a server-ordered, per-property approach like this for its multiplayer canvas. Deleting a shape someone else is editing needs a rule too (usually: delete wins, and the editor shows that the shape is gone).
- 4
Text needs more: OT and CRDTs. Text ops use positions, and positions shift. If Ravi inserts
Heyat position 0 while Asha inserts!at position 5 ofHello, applying Asha's op unchanged after Ravi's givesHey H!ello. Operational transformation (OT) fixes this by transforming an op against ops that happened concurrently: Ravi's insert of 4 characters before position 5 moves Asha's insert to position 9, givingHey Hello!on both screens. OT usually relies on a central server that orders ops and transforms them; Google Docs is widely described as OT-based. A CRDT (conflict-free replicated data type) instead gives every character a unique id (for example user id plus counter) and inserts "after character X" instead of "at position 5"; deletes leave a hidden marker (a tombstone). Merging is designed so applying the same set of ops in any order gives the same result, so peers can sync without a central authority and work offline for long periods. The costs are extra memory for ids and tombstones and harder-to-control intent in some edge cases. - 5
In practice, use a library. Writing correct OT or CRDTs for rich text is research-grade work. Teams use Yjs or Automerge (CRDT libraries, with bindings for editors such as ProseMirror, TipTap, Lexical and CodeMirror), or hosted services such as Liveblocks that provide storage, presence and sync. The React side subscribes to the shared document (often with
useSyncExternalStore, Phase 10.3) and renders it; local edits go into the library, which sends updates through a provider (WebSocket, WebRTC or the service). - 6
Presence, undo, autosave. Presence (who's here, their cursor and selection, colour) is sent as frequent, ephemeral messages, throttled to around 20–30 updates per second, and never saved in the document. Undo in a shared doc must undo only your own ops (not Ravi's typing) and must apply the *inverse* op transformed against everything that happened since; libraries track who made each change for this. Autosave is continuous: ops are sent as they happen, the UI shows "Saving…" then "All changes saved" (and "Offline, changes will sync" when disconnected), and unsent ops are kept locally (IndexedDB) so a closed tab or a crash doesn't lose work. Conflict handling after a long offline period means rebasing local ops onto the server's history (OT) or simply merging (CRDT), and showing the user if something they edited was deleted.
Explain it without notes
Why do collaborative apps send operations instead of snapshots?
Explain OT and CRDTs in simple terms and compare them.
How would you design conflict handling for a whiteboard?
How do presence cursors work, and why are they separate from the document?
How do undo and autosave work in a collaborative editor?
Practice
Write transformInsertDelete(insert, del) for a delete { pos, length } that happened at the same time as an insert.
Add a third user to the whiteboard example who changes y, and show that all three changes survive with operations.
Trade-offs
- ↔
OT with a central server is compact and well understood for text but hard to implement for rich structures; CRDT libraries handle offline and peer-to-peer merging but use more memory per document.
- ↔
Per-property last-writer-wins is simple and works well for canvases, but two people typing in the same text field would overwrite each other; text inside shapes needs a text CRDT or OT.
- ↔
Very frequent presence updates feel live but cost bandwidth and renders in large rooms; throttle, and show only nearby or recent cursors when many people are present.
Done when you can
I can explain why operations beat snapshots for collaboration.
I can explain OT and CRDTs simply and compare them honestly.
I can design per-property conflict handling for a whiteboard.
I can design presence that is separate from the document.
I can explain per-user undo and continuous autosave.