← System design

Communication

Design Collaborative Whiteboard

A shared whiteboard feels like a toy until forty people drag sticky notes at the same moment during a retro. Then you need edits that merge on every screen without a referee, a canvas that loads quickly even when it holds sixty thousand shapes, cursors that move smoothly without flooding the network, and an undo button that only takes back what you did. Think Miro, FigJam or Excalidraw with live collaboration.
Core: per-field CRDT + tiled viewport syncBoards up to 60K objects40 live editors on one boardRemote edit visible in under 150 ms

01

What the board must do

02

What the board must do

Functional
  • 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.
Non-Functional
  • 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.
Questions worth asking first

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

03

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.

QuantityWorkingResult
Peak concurrent sockets3M x 35 min / 1,440 min, times 3 for the weekday peakabout 220K
Users actively editing at peakabout 40% of connected usersabout 88K
Edit operations in88K x 2 ops / s after drag coalescingabout 176K / s
Edit messages out176K x 6 other people in an average roomabout 1.05M / s
Cursor frames out220K recipients x 10 batched frames / sabout 2.2M / s
Typical heavy board60K objects x about 400 B encodedabout 24 MB of state
Objects in a normal viewportzoomed to read text150 to 400
Operation log written176K / s x about 110 Babout 19 MB / s, 1.6 TB a day
Stored boards150M boards x 600 KB average snapshotabout 90 TB in object storage
Room servers needed220K sockets / 15K per node, with headroomabout 20 to 25 nodes
Read the table this way

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.

Where the 24 MB matters

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

04

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.

WS/v1/boards/{board_id}/liveEdits, sync and presence
// Binary frames over one socket per open board // client to server on connect { "t": "hello", "board": "brd_7Qx2", "stateVector": "<clientId:clock pairs>", "view": [-1200, 300, 2400, 1400] } // server replies with only what the client is missing inside its view { "t": "sync", "tiles": ["z3:-1:0", "z3:0:0"], "update": "<CRDT delta bytes>" } // either direction, many per second { "t": "op", "seq": 5812, "update": "<CRDT delta bytes>" } { "t": "ack", "upTo": 5812 } // durable in the op log // ephemeral, never stored { "t": "presence", "x": 412.5, "y": -88, "sel": ["obj_k2"] } { "t": "view", "rect": [-200, 0, 1600, 900] } // sent after pan or zoom
POST/v1/boardsCreate a board
{ "title": "Q4 planning retro", "template": "tpl_retro_3col", "workspace": "ws_481" } // 201 Created { "board_id": "brd_7Qx2", "live_url": "wss://live.example.com/v1/boards/brd_7Qx2/live" }
GET/v1/boards/{board_id}/tiles?z=3&keys=-1:0,0:0Cold load by tile, cacheable
// Used when the socket is not up yet or for a big jump across the board { "version": "sv_19ab", "tiles": { "-1:0": "<objects encoded>", "0:0": "<objects encoded>" } }
POST/v1/boards/{board_id}/assetsGet an upload URL for an image or PDF
{ "mime": "image/png", "bytes": 2381044 } // returns a presigned URL; the board stores only the asset id and its size on canvas { "asset_id": "ast_90c", "upload_url": "https://uploads.example.com/..." }
GET/v1/boards/{board_id}/versions?before=2026-09-30T18:00:00ZList restorable checkpoints
{ "versions": [ { "id": "v_3301", "at": "2026-09-30T17:42:10Z", "editors": ["u_12", "u_88"] } ] }

04

How the pieces fit

05

How the pieces fit

Live path, durable path and cold loadDiagram
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.

Room server

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.

Presence relay

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.

Op log

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.

Snapshot store

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

06

Deep dive: merging, viewport loading and per-user undo

The three problems that decide the design

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?

One edit from drag to convergenceDiagram
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.

Client side, too

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

!
Shipping the whole board to every client when it opens

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.
!
One lock or one sequence number per board, granted by the server

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.
!
A single undo history for the whole board

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.
!
Persisting cursor positions with the board

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

07

Choices and their price

Chosen
CRDT with field-level last-writer-wins

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 canvas
Alternative
Operational transformation

A 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 only
Chosen
One authoritative room server per board

A 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 fast
Alternative
Peer-to-peer mesh between browsers

Lowest 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 editors
Chosen
Freehand strokes as finished, simplified paths

While 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 sync
Alternative
Every pen sample as a stored op

Exact 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 whiteboard
Chosen
10 Hz batched cursors with interpolation

One 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 little
Alternative
Raw 60 Hz cursor broadcast

Perfectly 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 budget

08

When things break

08

When things break

🔥
The room server for a busy board dies

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.
🌀
A client returns after a week offline with 900 queued edits

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.
📣
A 400-person all-hands opens one board at the same minute

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.
🪦
Tombstones swamp an old board

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.
🧭
The spatial index drifts from the real positions

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

09

Interview playbook

1

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.

2

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.

3

Do the fan-out maths. Show that presence messages outnumber edits, then explain the batching, viewport filtering and caps that keep them bounded.

4

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.

5

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.

6

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.

7

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

11

From sketchpad to platform

Stage 1: one person at a time
A canvas saved as one JSON document

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.

Stage 2: small teams
Sockets and whole-object last-write-wins

A socket server relays changes. Conflicts resolve per object, so a recolour and a move can clobber each other, but small teams rarely notice.

Stage 3: real multiplayer
Field-level CRDT, op log and acks

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.

Stage 4: big boards
Tiles, viewport interest and level of detail

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.

Stage 5: an organisation's canvas
Per-user undo, history and compaction

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.