Communication
Design Collaborative Whiteboard
01
What the board must do
What the board must do
- Users place and edit sticky notes, shapes, connectors, text, images and freehand strokes on an unbounded canvas.
- Everyone on the board sees other people's edits and live cursors with name labels while they happen.
- Pan and zoom freely, from a whole-board overview down to a single note.
- Undo and redo affect only the current user's own actions, never a teammate's.
- A user who loses the network keeps drawing; their changes merge in when the connection returns.
- Owners share a board by link with edit, comment or view rights, and can browse earlier versions.
- A remote edit shows up on other screens within 150 ms at p95 for users in the same region.
- Opening a board with 60,000 objects becomes interactive in under 2 seconds.
- No acknowledged edit is ever lost, even if the server holding the session dies.
- Every replica converges to the same board once all edits arrive, whatever order they arrive in.
- Presence traffic stays bounded as a board grows from 5 viewers to 300 watching a town hall.
Pin down the largest board and the largest crowd you must support, whether viewers outnumber editors, and how long offline edits can pile up. A board with 300 passive viewers and 5 editors needs a very different presence plan from one with 40 people all moving notes. Also ask whether version history must be exact (every keystroke) or coarse (snapshots every few minutes), because that sets the size of the operation log you keep.
02
Sizing the traffic
Sizing the traffic
Assume 25 million monthly users, 3 million of them active on a given day, with sessions that average 35 minutes. Most boards are small, but the design is judged on the heavy ones: workshop boards that collect tens of thousands of objects over a year.
| Quantity | Working | Result |
|---|---|---|
| Peak concurrent sockets | 3M x 35 min / 1,440 min, times 3 for the weekday peak | about 220K |
| Users actively editing at peak | about 40% of connected users | about 88K |
| Edit operations in | 88K x 2 ops / s after drag coalescing | about 176K / s |
| Edit messages out | 176K x 6 other people in an average room | about 1.05M / s |
| Cursor frames out | 220K recipients x 10 batched frames / s | about 2.2M / s |
| Typical heavy board | 60K objects x about 400 B encoded | about 24 MB of state |
| Objects in a normal viewport | zoomed to read text | 150 to 400 |
| Operation log written | 176K / s x about 110 B | about 19 MB / s, 1.6 TB a day |
| Stored boards | 150M boards x 600 KB average snapshot | about 90 TB in object storage |
| Room servers needed | 220K sockets / 15K per node, with headroom | about 20 to 25 nodes |
The write volume is modest. The expensive part is fan-out: one dragged note becomes six outbound messages, and every cursor becomes a stream to everyone else. Presence traffic outnumbers real edits by about two to one, so it gets its own budget and its own rules.
Sending a heavy board to a phone on hotel Wi-Fi is the actual latency risk. The client should only ever download the part of the board it can see, plus a cheap low-detail overview for the minimap.
03
Client and server contract
Client and server contract
Live collaboration runs over a single WebSocket per open board. Plain HTTP handles everything that is not real time: creating boards, uploading images, history and exports.
04
How the pieces fit
How the pieces fit
flowchart LR
C["Browser canvas with local CRDT"] --> GW["Socket gateway"]
GW --> RT["Board router"]
RT --> RS["Room server owning the board"]
RS --> PR["Presence relay, memory only"]
RS --> OL[("Op log, per-board stream")]
OL --> CP["Compactor"]
CP --> SN[("Snapshot store, tiled")]
RS --> SN
C --> API["Board API"]
API --> MD[("Board metadata and ACLs")]
API --> SN
C --> CDN["CDN for images and tiles"]
CDN --> AS[("Asset storage")]Each open board lives on exactly one room server at a time. The board router hashes board_id onto the room fleet and pins every socket for that board to the same process, so the server can merge edits in memory and fan them out without asking anyone else. A room with nobody connected costs nothing: the room server loads the board when the first user arrives and drops it a few minutes after the last one leaves.
An edit takes two paths at once. The room server applies it to its in-memory copy, broadcasts it to the other sockets in the room, and appends it to the board's stream in the op log. Only after the append succeeds does it send an ack to the author. The client keeps unacknowledged operations in a local queue, so a crash between broadcast and append loses nothing: the author simply resends them to whichever server takes over.
Behind the live path, a compactor folds the op log into tiled snapshots every few hundred operations or every minute, whichever comes first. Snapshots are what cold loads, history views and exports read, which keeps those workloads off the room servers.
Holds the live CRDT document and a spatial index of every object's bounding box. It tracks each socket's viewport so it can send edits only to people who can see them.
Cursors, selections and "who is here" never touch storage. The relay batches them into one frame per recipient every 100 ms and drops stale positions instead of queueing them.
An append-only stream per board, in Kafka or a partitioned table keyed by board and sequence. It is the source of truth until a snapshot has absorbed it.
Object storage holding the board cut into spatial tiles, each tile compressed separately, plus a small overview image. A CDN can cache tiles because every version has its own key.
05
Deep dive: merging, viewport loading and per-user undo
Deep dive: merging, viewport loading and per-user undo
Concurrency: how do forty people edit one object graph without locks? Size: how does a phone open a board it cannot fit in memory? And intent: how does undo reverse my action without destroying yours?
flowchart TD E["User drags note obj_k2"] --> L["Apply locally, render at once"] L --> Q["Push op to unacked queue"] Q --> S["Send to room server"] S --> M["Server merges into its CRDT copy"] M --> B["Broadcast to sockets whose view covers obj_k2"] M --> A["Append to op log"] A --> K["Ack to author, drop from queue"] B --> R["Peers merge: same result in any order"]
Model the board as a map of objects, each a map of fields
A whiteboard is not a text document. There is no single sequence that everyone inserts into. It is a set of independent objects, each with a handful of properties: position, size, colour, text, z-order, parent frame. That shape suits a CRDT very well. The board is a map from object id to object, and every object is a map from field name to a last-writer-wins register.
Each register stores its value plus a stamp of (lamport_clock, client_id). When two updates to the same field meet, the higher clock wins, and the client id breaks ties. The rule is deterministic, so every replica picks the same winner no matter which update it saw first. Two people dragging the same note at once is the common conflict; with this model one of them ends up holding it, and nobody sees the note split in two.
Field granularity is what makes this pleasant. If Priya recolours a note while Marco moves it, those are different registers and both changes survive. Only a true clash on the same field resolves by stamp. Text inside a note or a text box is the exception: two people typing in one paragraph need a sequence CRDT for that field, the same kind a document editor uses, so letters interleave instead of one person's sentence replacing the other's.
Creation and deletion fit the same scheme. Creating an object is writing a new key with a client-generated id, so no server round trip is needed to get an id. Deleting sets a deleted register to true. The object becomes a tombstone that sticks around until compaction can prove every replica has seen it, which is what stops a late "move" from resurrecting a note someone already threw away.
Z-order without renumbering
Stacking order is a classic trap. Storing an integer index means "bring to front" renumbers many objects, and concurrent reorders collide. Instead give each object a fractional index, a string key that sorts lexically. Putting a shape between keys "a3" and "a4" generates something like "a3V". One register changes per reorder, and if two people pick the same key the client id breaks the tie.
Load only what the viewport can see
The room server keeps every object's bounding box in a spatial index. An R-tree works; so does a simple quadtree of fixed tiles, which has the advantage that tiles double as cache keys for cold loads. When a client says its view is the rectangle [-1200, 300, 2400, 1400], the server finds the tiles that overlap it, plus a margin of one tile on every side so that a small pan does not trigger a fetch, and streams only those objects.
Zoom changes the picture. At 5% zoom a viewport might cover the whole 60,000-object board, and drawing every sticky note is pointless when each is two pixels wide. So each tile also stores a level-of-detail summary: coloured rectangles for clusters of notes and frame titles, generated by the compactor. Below a zoom threshold the client renders summaries; it fetches real objects only once the user zooms in far enough to read.
Edits respect the same interest set. When someone moves a note, the room server sends the update to sockets whose padded viewport contains the note's old or new box. Others receive nothing until they pan there, at which point the tile they request already includes the change. For a 300-person all-hands board where everyone is zoomed into different corners, this cuts fan-out by an order of magnitude.
The browser keeps its own spatial index over loaded objects (RBush is the usual choice) for hit-testing clicks and lasso selection. Rendering goes through a canvas or WebGL layer, not one DOM node per shape, because a few thousand DOM nodes is where pan and zoom start to stutter.
Undo that belongs to one person
A shared undo stack is wrong for multiplayer. If I press Ctrl+Z, I expect my last move to revert, not the note my colleague just wrote. So every client keeps its own undo stack, and each entry records the inverse of a local action: for a move, the previous position; for a delete, the full object as it was.
Undo never rewinds history. It issues a new forward operation that writes the old values back, stamped with a fresh clock, so it merges like any other edit and every replica agrees on the result. Redo is the same trick in the other direction.
The subtle case is when someone else touched the same field after you. Say you moved a note left, then a teammate moved it right. If your undo blindly restored your old position, it would trample their newer move. A good rule: before applying an inverse, compare the field's current stamp with the stamp your action produced. If someone else has written since, skip that field (or the whole entry) and move on. Undo should feel like it rewinds your intent, and quietly giving up on a contested field is less surprising than reverting a teammate's work.
Group operations into undo entries by gesture, not by message. A two-second drag might send forty position updates, but one Ctrl+Z should put the note back where the drag started. The client opens an entry on pointer-down and closes it on pointer-up.
Cursors without a flood
Cursors are the noisiest data on the board and the least valuable to keep. Clients sample the pointer at most every 50 ms and skip a send if it moved less than a couple of pixels. The presence relay merges all cursors in a room into one frame per recipient every 100 ms, includes only people whose cursor falls inside that recipient's padded viewport, and caps a frame at the 25 nearest cursors. Receivers interpolate between frames so motion still looks smooth. Losing a frame is harmless, which is why presence uses no acks and no log.
Coming back from offline
Offline edits are just operations that waited. The client writes them to IndexedDB as it goes. On reconnect it sends its state vector, the highest clock it has seen from each client id, and the server answers with exactly the operations the client is missing; the client then sends its queue. Because the CRDT merges in any order, no special conflict screen is needed. If the client has been away so long that the server has compacted past its state vector, the server sends fresh tiles for the viewport instead, and the client replays its queue on top.
06
Traps that look reasonable
Traps that look reasonable
A heavy board weighs tens of megabytes. Downloading and parsing all of it on a laptop takes seconds, and on a phone it can exhaust memory. Most of those objects are far outside the screen.
Instead: stream tiles that overlap the padded viewport, use level-of-detail summaries when zoomed out, and fetch more tiles on pan.Every edit waits a full round trip before it renders, so dragging feels sticky at 120 ms of latency, and nothing works offline. The server also becomes a bottleneck for every keystroke.
Instead: apply edits locally right away and let a CRDT merge them. The server orders operations for durability, not for permission.Ctrl+Z ends up reverting whatever happened last on the board, often a colleague's work. People stop trusting undo, and in a big session they stop using it.
Instead: keep a separate stack per user of inverse operations, apply them as new edits, and skip fields someone else has changed since.Cursors arrive ten times a second per person. Writing them to the op log multiplies storage several times and slows compaction, for data that is useless a second later.
Instead: route presence through a memory-only relay, batch it per recipient, and let lost frames stay lost.07
Choices and their price
Choices and their price
Edits apply instantly and merge in any order, offline included. Shapes conflict rarely and cheaply. The price is metadata on every field and tombstones that need garbage collection.
Best fit for an object canvasA central server transforms concurrent operations against each other. It works for linear text, and wire messages are lean, but it needs that server in the loop for every edit, which makes long offline sessions awkward.
Reasonable for text onlyA single process merges, fans out and persists for a board, which keeps ordering, permissions and viewport filtering simple. A rebalance or crash moves the board, costing users a reconnect of a second or two.
Simple and fastLowest possible latency and no server bill. But access control and abuse handling are hard, mesh size caps out around a dozen peers, and you still need a server for storage and for late joiners.
Not at 40 editorsWhile drawing, points stream as presence so others see the line grow. On pen-up the client simplifies the path and commits one immutable object. A stroke then costs one operation instead of hundreds.
Cheap to store and syncExact replay of the drawing, which some art tools want. A busy pen session can produce thousands of operations a minute, bloating the log and slowing every cold load.
Too heavy for a whiteboardOne frame per recipient per 100 ms, filtered by viewport. Interpolation on the receiving side hides the gaps. Bandwidth stays a fraction of a naive 60 Hz broadcast.
Looks smooth, costs littlePerfectly faithful motion. Six times more messages, and in a 100-person room each client receives thousands of updates a second, most for cursors it cannot even see.
Wastes the presence budget08
When things break
When things break
Forty sockets drop at once. Some operations were broadcast to peers but never appended to the log, so peers hold edits that durable storage does not.
Clients reconnect through the router, which assigns a new room server. That server loads the latest snapshot plus the log tail. Authors resend their unacked queue and peers resend what they hold; CRDT merge makes duplicates harmless, so the board converges with nothing lost.The server compacted that board days ago, and some objects the client edited were deleted by others in the meantime.
The client sends its state vector, receives fresh viewport tiles and replays its queue. Edits to tombstoned objects are dropped by the merge rule. Show a small "synced 900 changes, 12 skipped" note so the user knows what happened.Join storms hit the room server with hundreds of full syncs, and presence fan-out grows with the square of the room size.
Serve initial tiles from the CDN-backed snapshot store instead of the room server. Put viewers above a threshold into watch-only mode, where they get edits but their own cursors are hidden unless they opt in.A board used for a year of weekly retros holds 50,000 deleted notes. Cold loads ship metadata nobody can see, and memory on the room server balloons.
During compaction, purge tombstones older than the oldest state vector seen in the last 30 days. Clients older than that are told to resync from tiles instead of merging deltas.A bug drops some bounding box updates, so a moved note stays indexed in its old tile. Users panning to it see an empty area until they refresh.
Rebuild the index from the CRDT state whenever a board is loaded into a room, and run a periodic check that compares a sample of objects with their indexed tiles.09
Interview playbook
Interview playbook
Name the data model before the servers. Say "a map of objects, each a map of last-writer-wins fields" in your first five minutes. It explains conflict handling and offline support in one sentence, and the rest of the design follows from it.
Contrast it with a text editor. Interviewers like to hear why a whiteboard needs a map CRDT plus spatial indexing, while a document needs a sequence CRDT. Mention that text fields inside notes still need the sequence kind.
Do the fan-out maths. Show that presence messages outnumber edits, then explain the batching, viewport filtering and caps that keep them bounded.
Walk through the undo edge case. Describe what happens when you undo a move that a teammate has since overridden. Having an answer for that one scenario signals real depth.
Make "infinite" concrete. Explain tiles, level-of-detail summaries and lazy loading by viewport. A canvas is only infinite if the client never has to hold all of it.
Separate durable from ephemeral data. Edits go to the log and get acks; cursors go through a relay and can be dropped. Drawing that line explicitly prevents half the follow-up questions.
Cover the crash story. Interviewers will kill the room server. Explain unacked queues, snapshot plus log replay, and why duplicates do no harm.
10
From sketchpad to platform
From sketchpad to platform
The client renders everything and saves the whole board on each change. Collaboration means "refresh to see what others did". Enough to validate the drawing tools.
A socket server relays changes. Conflicts resolve per object, so a recolour and a move can clobber each other, but small teams rarely notice.
Edits merge per field, the author waits for an ack from durable storage, and cursors move to a separate presence relay. Offline queues come almost for free.
Snapshots are cut into spatial tiles behind a CDN. Room servers fan out by viewport, and zoomed-out views render summaries. Board size stops affecting load time.
Undo becomes per user, version history reads from snapshots, tombstones are collected, and watch-only mode handles all-hands crowds.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
