Engineering Atlas › System Design Interviews

Design a collaborative whiteboard

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: rich text inside a shape (see the Google Docs chapter), video calls and billing.

Back-of-envelope

API

Clients talk to the board over one WebSocket. Reads of metadata and comments use plain HTTP.

GET  /boards/{id}/open?resume_seq=1840      -> 101 upgrade; or 200 {snapshot_url, seq} for a cold load

client -> server
  {t:"hello", user, last_seq}                # last_seq lets the server send only the missing tail
  {t:"op", cid:"c91-17", ops:[{obj, prop, value}]}   # cid = client op id, for idempotent retry
  {t:"presence", cursor:[x,y], sel:["r1","r7"], vp:[x0,y0,x1,y1]}   # ephemeral
  {t:"viewport", vp:[x0,y0,x1,y1]}           # what I can see; for large boards

server -> client
  {t:"ack", cid, seq}                        # my op got this sequence number
  {t:"ops", from_seq, to_seq, ops:[...]}     # other people's changes, in order
  {t:"reject", cid, reason:"cycle"}          # the server refused (for example a reparent loop)
  {t:"presence_frame", users:{u7:{cursor, sel}, ...}}   # tick-batched

POST /boards/{id}/comments        {anchor:{obj|point}, body, parent_id}
GET  /boards/{id}/versions        -> [{seq, at, label}]
POST /boards/{id}/restore         {seq}

Data model

Live document (in the board process, memory): Map<ObjectID, Map<Property, Value>>. The tree is expressed through a parent property on the child and a position property, a fractional index string that orders siblings. Figma describes its document in this shape, and keeps the parent link on the child so object identity survives a move (Figma).

Journal (key-value store, partition key board_id, sort key seq_start): each item is a batch {seq_start, seq_end, ops[]}. Partition by board so one board's replay is one range scan. A lock row (board_id -> owner_uuid, lease_expiry) makes appends conditional so only the owner can write.

Snapshots (object store): boards/{id}/{seq}.bin, a compressed binary encoding of the full map plus the seq it covers. A small latest pointer row names the newest.

Comments, users, permissions (Postgres, partition by board_id): ordinary relational rows with their own sync channel. Figma keeps these outside its multiplayer system for the same reason: different trade-offs for offline use, security and performance (Figma).

Assets (object store plus CDN): images by content hash. The document holds only the hash.

Presence: not stored. It lives in the board process, keyed by connection, and expires on disconnect.

High-level design

flowchart LR
  C1["Client A (local state)"] <-->|WebSocket| GW["Gateway / router"]
  C2["Client B"] <-->|WebSocket| GW
  GW -->|"board_id -> owner"| BP["Board process (in-memory doc, seq counter)"]
  BP -->|"batched ops, conditional on lock"| J[("Journal (KV)")]
  BP -->|"checkpoint every 30-60 s"| S[("Snapshots (object store)")]
  BP -->|"ops + presence frames"| GW
  LK[("Lock / lease store")] --- BP
  API["HTTP API"] --> PG[("Postgres: comments, ACL")]
  C1 -->|"upload by hash"| OBJ[("Assets + CDN")]

Write path. A user drags a rectangle. The client applies the new x and y locally and sends an op with a client id. The gateway routes by board_id to the one process that owns the board. That process validates the op (permission, schema, no reparent cycle), assigns the next sequence number, updates the in-memory map, acknowledges the sender, and broadcasts the op to everyone else. A background task appends the batch to the journal under the lock, and a timer writes a snapshot.

Read path. A cold open fetches the latest snapshot from the object store (through a CDN-style cache), then asks the board process for ops since the snapshot's seq and joins the live stream. A reconnect sends last_seq in hello and receives only the tail. If the tail is too long or no longer retained, the server sends a fresh copy instead.

Presence path. Cursors go to the board process, which keeps the latest per user and emits one presence_frame per tick to each viewer, filtered to that viewer's interest.

Deep dives

1. Ordering: server authority versus CRDTs

Options:

Choice: server-ordered LWW, because a whiteboard has a natural central point (the board) and users are almost always online. Use CRDT-style techniques only where concurrency needs to be conflict-free by construction. Two examples. Sibling order uses fractional indexing: a new item between positions "a" and "b" gets a string between them, so an insert edits one value. The cost is index growth and possible interleaving, which Figma judged acceptable for design documents, and it breaks the tie of two identical indices by having the server assign a unique position to the second insert (Figma). Reparenting stores the parent on the child, and the server rejects any change that would create a cycle, which is one of the rules a central orderer makes easy.

2. Large boards and large rooms

A 100k-object board is 5 to 30 MB. Sending it whole on every open is slow and wasteful.

3. Snapshots plus an op log, and offline edits

Memory is volatile, so state needs a durable form. Figma first checkpointed the whole file every 30 to 60 seconds, which meant up to a minute of lost work after a crash, and a surge of checkpoints on every deploy. It added a journal: every change gets an incrementing sequence number, checkpoints record the sequence they cover, and recovery loads the checkpoint and then replays journal entries with a higher sequence (Figma).

Details that matter:

Down the stack

Layer 1The contract

  • Convergence. After all messages are delivered, every client's board equals the server's board, with no extra rounds of merging.
  • Per-property last-writer-wins. For one (object, property), the value with the highest server sequence number wins. Writes to different properties are independent.
  • Acknowledged means journaled soon. An ack guarantees ordering, not durability. Durability follows within the batch interval (target under 1 s). The client keeps unacknowledged ops and resends them with the same cid after a reconnect, and the server drops duplicates by cid.
  • Rejections are explicit. Invalid ops (cycle, no permission, schema violation) return reject, and the client rolls its local value back to the server's.
  • Presence is best effort. It may drop or lag, and it disappears within a few seconds of a disconnect.

Layer 2The mechanism

  • Sequence stamping. The board process holds a counter. Each accepted op becomes (seq, obj, prop, value). Because one thread owns the board, there is no lock around the counter.
  • Cycle check. For parent = P on object O, walk from P up through parent links. If you reach O, reject. The walk is bounded by tree depth.
  • Presence protocol. Libraries such as Yjs treat awareness as separate from the document: it is not persisted, and a client that goes offline has its state removed and others are told (Yjs docs). Do the same with a heartbeat: if a connection is silent for about 30 s, evict its presence.

Layer 3The machine

  • WebSocket frames. Each message has a 2 to 14 byte header, and client-to-server frames carry a 4-byte masking key. Ping and pong control frames detect dead peers (RFC 6455). A 40-byte cursor update becomes about 50 bytes on the wire plus TCP and TLS overhead, so batching many into a frame matters more than compressing them.
  • Tick loop. The board process runs a 30 to 100 ms tick. In each tick it drains inbound messages, applies ops, and builds one outbound frame per recipient. This turns an O(n^2) message storm into O(n) frames per tick.

Why it's built this way

  1. People are sensitive to lag of about 100 ms in what they see, so changes apply locally first and the server only orders them.
  2. Orderings that disagree are the root of conflicts, so one owner per board creates a single order and removes the need for merge algorithms.
  3. One owner per board makes memory the source of truth, which is volatile, so a journal with sequence numbers plus periodic snapshots bounds data loss and recovery time.
  4. A journal write per 33 ms update would swamp storage, so intermediate state is ephemeral and the durable stream is batched and coalesced, which last-writer-wins permits.
  5. Cursor traffic scales with the square of viewers, so presence is tick-batched, filtered by viewport and kept off disk.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Server-ordered per-property LWWSimple, can enforce rules (cycles, permissions), small metadataNeeds a live owner per board, offline merges are last-writerOnline-first tools, object-based canvases
General CRDT (Yjs, Automerge)Peer-to-peer, long offline, no central ordererTombstones and ids grow, harder to enforce invariantsLocal-first products, rich text inside shapes
Operational transformationCompact steady state, good for textComplex transform functions, needs a central serverLong text documents
Journal plus snapshotsBounded loss, fast deploysTwo stores to keep consistent, gap handlingMemory-resident state
Viewport-sliced loadingFast open, less bandwidthMore protocol, harder cursor and selection logicLarge infinite canvases

Leadership lens

Check yourself

  1. Two users drag the same rectangle at the same time, one changing x and the other changing fill. What does each see, and why is it different if both change x?

    Show answer

    Both changes survive, because x and fill are separate properties. If both change x, the later arrival at the board process wins and everyone converges to that value.

  2. Your journal replays sequence numbers 1 to 40, then 42 to 50. What do you do and why?

    Show answer

    Stop and fall back, because batch 41 is lost and loading the rest would produce a board that never existed. Alert, use an earlier snapshot, and mark the board for review.

  3. Why not store cursors in the journal like shapes?

    Show answer

    They are 100x more frequent, are worthless a second later, and would turn a 10k writes/s journal into a million. Presence is broadcast from memory and dropped on disconnect.

  4. A 100-person workshop board is slow. Where does the load come from, and what changes?

    Show answer

    Cursor fan-out grows as editors times viewers: 1,000 updates/s in becomes 99k messages/s out. Tick-batch into frames, lower the rate with viewer count, filter by viewport, and send view-only users through relay processes.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →