Design a collaborative whiteboard (Miro)
The interviewer asks
“Design a collaborative whiteboard like Miro or FigJam: an infinite canvas where teams add sticky notes, shapes, connectors, freehand drawings and images together in real time, with live cursors, for boards that can hold 100,000 objects and 300 people in a workshop.”
What a strong answer looks like
A whiteboard is collaborative editing on a set of objects, not on a text string, which makes conflict handling simpler than Google Docs. A strong answer models the board as a map of object_id → properties and syncs per-property changes with last-writer-wins registers ordered by a server sequence (the approach Figma described publicly), using fractional indexing for z-order and parent references for grouping. Each active board is owned by one session server that orders changes, persists them to an op log, and fans them out over WebSockets. The interesting parts are what is special about canvases: very high-frequency ephemeral data (cursors, drag previews) that must be throttled and never stored, viewport-based loading for huge boards using a spatial index, freehand strokes as compact point lists, per-user undo in a shared space, and large workshop sessions where one board gets hundreds of editors at once.
Concepts to mention
1 Requirements
Functional
- Infinite canvas with pan and zoom
- Create, move, resize, style and delete objects: sticky notes, shapes, text, connectors, frames, images, freehand ink
- Real-time multi-user editing with live cursors and names
- Undo/redo per user, copy/paste, grouping, z-order (bring to front)
- Comments on objects, board version history
- Share via link with view, comment or edit roles; export to image or PDF
Non-functional
- Remote changes visible p95 under 150 ms in the same region; cursor movement looks smooth
- Boards up to 100k objects; first render under 2 s
- Up to 300 concurrent editors on one board during a workshop
- No loss of committed changes; offline for short periods with sync on reconnect
- 10M monthly active users, ~500k boards open concurrently at peak
Out of scope (say it out loud)
- Video/audio calls inside the board
- Template marketplace
- Rich-text collaborative documents embedded in the board (see collaborative editor)
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
GET/v1/boards/{board_id}?viewport=x1,y1,x2,y2 | Load board metadata, latest snapshot reference, sequence number, and objects in or near the viewport first |
WS/v1/boards/{board_id}/live | Send changes {client_id, client_seq, changes:[{object_id, prop, value}]}, receive committed changes with server seq, presence and cursors |
POST/v1/boards/{board_id}/assets | Get a pre-signed URL to upload an image or file; returns asset_id referenced by an image object |
GET/v1/boards/{board_id}/versions | List saved versions; restore creates new changes |
POST/v1/boards/{board_id}/export | Async export job to PNG/PDF; returns job_id |
PUT/v1/boards/{board_id}/sharing | Set link and member roles |
4 Data model
boardsPostgresboard_id (PK)workspace_idtitleowner_idlast_seqsnapshot_keysnapshot_sequpdated_atMetadata and pointers; ACLs live alongside or in an authz service.
objects (materialised)Snapshot in object storage + in memory on session serverobject_id → {typeparent_idxywhrotationz_index (fractional string)styletextpointsasset_iddeleted}Board state is a map of objects. Loaded fully into the session server for active boards.
change logCassandra / DynamoDBboard_id (partition)seq (clustering)client_idclient_seqauthor_idchanges (object_id, prop, value)tsAppend-only, ordered by server seq; used for recovery, history and catch-up after reconnect.
spatial indexIn-memory R-tree on session server (and in client)object bounding boxesAnswers 'which objects intersect this viewport' for viewport loading and culling.
assetsS3 + CDNasset_idboard_idoriginalthumbnails at several sizesClients load low-resolution thumbnails when zoomed out.
commentsPostgrescomment_idboard_idobject_id or positionthreadauthorresolvedLow volume, relational.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Open a large board
- 1Client calls GET /boards/{id}; API checks permission and returns the snapshot location, snapshot_seq and the session server address
- 2Client downloads the snapshot (compressed, ~5-10 MB for a big board) or, for huge boards, objects near the viewport first and the rest progressively
- 3Client builds a local spatial index and renders only objects in the viewport, using GPU canvas (WebGL) for thousands of shapes
- 4Client opens a WebSocket to the board's session server and asks for changes after snapshot_seq to catch up
- 5Images load as small thumbnails from the CDN and switch to full resolution when zoomed in
Drag a sticky note while others edit
- 1During the drag, the client sends ephemeral 'drag preview' messages (~10-20/s) that the server relays but does not store
- 2On drop, the client applies the change locally and sends a committed change {object_id, x, y} with client_seq
- 3Session server assigns the next seq, appends to the change log, updates its in-memory board, and broadcasts to other clients
- 4If two users moved the same note at once, the one with the later server seq wins for those properties; the other client sees its note snap to the winner's position
- 5Text edits inside a sticky note are a separate property; concurrent edits to one note's text are rare, so last-writer-wins on the text property is usually accepted, or a small text CRDT is used for longer text objects
Live cursors in a 300-person workshop
- 1Each client sends its cursor position at most ~10 times/s, only when it moves
- 2Session server batches all cursor updates for a board into one frame every ~50-100 ms
- 3It sends each client only cursors inside or near that client's viewport, plus a count of others
- 4Clients interpolate between positions so cursors look smooth at 60 fps without 60 updates/s
7 Deep dives
Data model and conflict resolution for objects
A board is a set of independent objects with properties, so most concurrent edits do not conflict at all: Alice moves one sticky note while Bob recolours another. The simplest correct model treats each (object_id, property) as a last-writer-wins register ordered by the server: every committed change gets a monotonically increasing seq from the board's session server, and the highest seq wins. Figma has described using exactly this kind of server-authoritative, property-level LWW, inspired by CRDTs but simpler because a central server exists. Because the conflict unit is a property, Alice changing colour and Bob moving the same note both survive. Clients apply their own changes optimistically and keep a list of unacknowledged changes; when a remote change arrives for a property they have a pending change on, they ignore the remote value, because their own change will be sequenced later and win anyway. Creation uses client-generated IDs (client_id + counter) so there are no ID collisions offline. Deletion is a 'deleted' property, so a concurrent move of a deleted object does not resurrect it. Long text inside objects is the one place where a text CRDT or OT is worth it.
Ordering, grouping and connectors
Z-order is tricky: if order is stored as an array of IDs, two users who 'bring to front' at the same time produce conflicting array edits. Use fractional indexing instead: each object stores a sortable string key, and moving an object between two others gives it a key strictly between their keys (for example between 'a' and 'b' you pick 'aV'). It is just another LWW property, so concurrent reorders never corrupt the list; ties are broken by object_id. Grouping and frames use a parent_id property, which can create cycles if Alice puts A inside B while Bob puts B inside A; the server validates and rejects changes that would make a cycle, and the client reverts. Connectors (arrows between shapes) store references to source and target object IDs plus an anchor side, not coordinates, so moving a shape automatically re-routes its arrows on every client. If a target is deleted, the connector keeps its last position or is deleted with it, by product rule. These details are where a whiteboard design shows depth, because they are the real bugs teams hit.
Ephemeral data: cursors, drag previews, selections
On a canvas, most of the network traffic is data nobody needs to store: cursor positions, the live position of a note while it is being dragged, selection boxes, laser pointers. Treat it as a separate, lossy channel. Clients throttle to ~10 updates per second and send only on change. The session server never writes it to the change log; it keeps only the latest value per user and drops older ones if the outgoing queue for a slow client fills (backpressure by dropping, not buffering). Broadcasts are batched per board every 50-100 ms so one WebSocket frame carries many cursors. For big workshops, cull by viewport: a user zoomed into the top-left corner does not need cursors from the bottom-right. Clients smooth motion with interpolation, so 10 updates/s look like 60 fps. Committed changes, in contrast, go through the ordered, durable path with acks. Mixing the two paths, by persisting cursor moves or by sending committed changes on the lossy channel, is a design error that interviewers notice.
Huge boards: loading, rendering and memory
A board with 100k objects and hundreds of images cannot be sent and drawn all at once on a laptop browser. On the server, the session server keeps the full object map in memory (tens of MB is fine) plus an R-tree spatial index. On open, the client can request objects intersecting its viewport first and stream the rest in the background, or download a compressed snapshot if it is small enough. On the client, rendering uses WebGL or a canvas with level-of-detail: when zoomed out, sticky notes are drawn as coloured rectangles without text, images use small thumbnails, and objects smaller than a pixel are skipped. Only objects in the viewport are drawn each frame, found via the client's own spatial index. Snapshots are rebuilt by workers every few thousand changes so that a new session server or a new client needs to replay only a short tail of the change log. Exports to PDF or PNG run as async jobs in headless renderers, because rendering 100k objects at high resolution can take minutes.
8 Trade-offs
Server-ordered LWW vs full CRDT
With a central session server per board, server-sequenced property-level LWW is simple, compact and easy to reason about. A full CRDT library (Yjs, Automerge) gives true offline and peer-to-peer merging but with more metadata and complexity. For an online collaboration product, server-ordered LWW is enough; use a CRDT only for rich text inside objects.
One session server per board vs stateless servers with a database
Stateless servers that write every change to a database and fan out through Redis pub/sub are simpler to operate but add latency and make ordering harder. A stateful owner per board keeps state in memory, orders changes cheaply and fans out directly; the cost is routing, rebalancing and recovery logic.
Commit on drop vs stream every drag frame
Committing every frame of a drag floods the log and history. Ephemeral previews during the drag plus one committed change on drop keep history clean and storage small.
Per-user undo vs global undo
Global undo would undo other people's work. Per-user undo stores the inverse of your own changes (previous property values) and applies them as new changes, skipping properties someone else has since changed.
9 Bottlenecks
300-user workshop on one board
Fan-out is n×n for cursors. Batch per tick, cull by viewport, cap visible cursors, and move view-only participants to read replicas that receive the committed stream from the owner.
Snapshot rebuild for very large boards
Serialising 100k objects often is expensive. Snapshot incrementally (by frame or region chunks) and only rewrite chunks that changed.
Image-heavy boards
Hundreds of full-size images freeze browsers. Generate thumbnails at upload, serve via CDN, and load full resolution only when zoomed in.
10 Failure handling
Session server crash
The router assigns the board to another server, which loads the latest snapshot plus the change log tail. Clients reconnect, request changes after their last seen seq, and resend unacked changes identified by (client_id, client_seq) so nothing is applied twice.
Client offline for a few minutes
Changes queue locally; on reconnect they are sent and sequenced. Because conflicts are per property with LWW, merges are predictable; the client shows which of its changes were overridden if needed.
Bad bulk change (someone deletes everything)
Version history and the change log allow restoring the board to a point in time, applied as new changes so everyone sees the restore.
11 What impresses
- Pointing out that objects make conflicts rarer and simpler than text editing
- Property-level LWW so a move and a recolour of the same object both survive
- Fractional indexing for concurrent reordering
- Clear split between ephemeral and durable data paths
- Doing the n×n cursor fan-out math for a workshop
- Client rendering strategy for 100k objects
12 Mistakes
- Saving the whole board as one JSON blob on every change
- Persisting every cursor movement
- Last-writer-wins on whole objects, losing concurrent changes to different properties
- Storing z-order as an array index
- Rendering every object every frame regardless of viewport
- Global undo that reverts other people's changes
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.