Design problems
Messaging & collaborationHardSDE-2 35 min

Design a collaborative whiteboard (Miro)

Asked at
AdobeAtlassianGoogleMicrosoftZoho

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

Board as a map of objects with propertiesProperty-level last-writer-wins ordered by server sequenceOptimistic local updates with unacked change listClient-generated object IDsFractional indexing for z-orderConnectors referencing object IDsSeparate ephemeral channel for cursors and drag previewsThrottling, batching and viewport cullingOne session server per board with an append-only change logSnapshots plus log tail for fast load and recoverySpatial index (R-tree) and level-of-detail renderingPer-user undo

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

Concurrent boards~500kPeak working hours; median board has 1-3 users online
Concurrent users~1.5M500k boards × ~3 users
Persisted changes~150k/s peak~1.5M users × ~1 committed change every 10 s (drags are committed on drop, not every frame)
Cursor updates~15M/s if unthrottled1.5M users × 10 updates/s; never persisted; fan-out only within a board, and only to users viewing nearby
Workshop board fan-out~3,000 cursor msgs/s in, ~900k/s out300 users × 10 cursor updates/s = 3k/s; × 300 receivers = 900k messages/s, so batching and culling are required
Board size~20-50 MB for 100k objects~200-500 bytes per object (type, position, size, style, text); images stored separately in object storage
Session servers~300-500~2,000 active boards and ~5k connections per server, sized by fan-out CPU

3 API

EndpointWhat it does
GET/v1/boards/{board_id}?viewport=x1,y1,x2,y2Load board metadata, latest snapshot reference, sequence number, and objects in or near the viewport first
WS/v1/boards/{board_id}/liveSend changes {client_id, client_seq, changes:[{object_id, prop, value}]}, receive committed changes with server seq, presence and cursors
POST/v1/boards/{board_id}/assetsGet a pre-signed URL to upload an image or file; returns asset_id referenced by an image object
GET/v1/boards/{board_id}/versionsList saved versions; restore creates new changes
POST/v1/boards/{board_id}/exportAsync export job to PNG/PDF; returns job_id
PUT/v1/boards/{board_id}/sharingSet link and member roles

4 Data model

boardsPostgres
board_id (PK)workspace_idtitleowner_idlast_seqsnapshot_keysnapshot_sequpdated_at

Metadata and pointers; ACLs live alongside or in an authz service.

objects (materialised)Snapshot in object storage + in memory on session server
object_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 / DynamoDB
board_id (partition)seq (clustering)client_idclient_seqauthor_idchanges (object_id, prop, value)ts

Append-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 boxes

Answers 'which objects intersect this viewport' for viewport loading and culling.

assetsS3 + CDN
asset_idboard_idoriginalthumbnails at several sizes

Clients load low-resolution thumbnails when zoomed out.

commentsPostgres
comment_idboard_idobject_id or positionthreadauthorresolved

Low volume, relational.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAimages, thumbsHTTP + WSopen boardWS by board_idsnapshotappend seq Nchanges, cursorscommitted changesnew snapshot, exportoriginBrowser / desktopcanvasLB + board routerCDN (assets, app)Board session serversBoard APIChange streamSnapshot + exportworkersChange log (Cassandra)Board metadata (PG)Snapshots + assets(S3)
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Open a large board

  1. 1Client calls GET /boards/{id}; API checks permission and returns the snapshot location, snapshot_seq and the session server address
  2. 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
  3. 3Client builds a local spatial index and renders only objects in the viewport, using GPU canvas (WebGL) for thousands of shapes
  4. 4Client opens a WebSocket to the board's session server and asks for changes after snapshot_seq to catch up
  5. 5Images load as small thumbnails from the CDN and switch to full resolution when zoomed in

Drag a sticky note while others edit

  1. 1During the drag, the client sends ephemeral 'drag preview' messages (~10-20/s) that the server relays but does not store
  2. 2On drop, the client applies the change locally and sends a committed change {object_id, x, y} with client_seq
  3. 3Session server assigns the next seq, appends to the change log, updates its in-memory board, and broadcasts to other clients
  4. 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
  5. 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

  1. 1Each client sends its cursor position at most ~10 times/s, only when it moves
  2. 2Session server batches all cursor updates for a board into one frame every ~50-100 ms
  3. 3It sends each client only cursors inside or near that client's viewport, plus a count of others
  4. 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

Q1How would you support a 'follow the presenter' mode?
Q2How would you add AI that clusters 200 sticky notes into themes?
Q3How do you handle a user who is removed from the board while connected?
Q4How would you support offline editing for a day?
Q5How would you store and sync freehand ink efficiently?
Q6How would you shard a single board that is too large for one server?

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.

Start free