Design a collaborative whiteboard
Open in the interactive app: practice, quiz, mock interview →In one minute
- Use the server as the single orderer. A whiteboard is a tree of objects with properties, not a long text. One process per board that stamps every change with a sequence number gives last-writer-wins per property, and it is far simpler than operational transformation or a general CRDT.
- Make the unit of conflict small. Model the board as
(object, property) -> value. Two people changing different properties, or different objects, never conflict. Only the same property on the same object does, and then the later arrival wins. - Split durable state from ephemeral state. Shapes and comments are logged and recoverable. Cursors, selections and drag-in-progress positions are broadcast and forgotten. Mixing them multiplies your write load by 100.
- Recover from a snapshot plus an op log. The in-memory board is checkpointed every so often. Between checkpoints, batched changes go to a journal with sequence numbers, so a crash loses under a second, not a minute.
- Fan out per board, and only what the viewer can see. Pin a board to one process, tick-batch presence, and send large boards lazily by viewport, because the hot case is one board with hundreds of viewers, not millions of boards.
Requirements
Functional
- Many users draw and edit shapes, text, connectors and images on one infinite canvas. Everyone sees changes within a fraction of a second.
- Live cursors, selections and user names for everyone on the board (presence).
- Comments pinned to a point or object, with replies and resolve.
- Editing continues through short disconnects and offline periods, and merges on return.
- Undo and redo per user. Version history and "restore this version".
- Share by link with view, comment or edit roles.
Non-functional
- Remote edits visible in under 150 ms in the same region at p95. Local edits apply instantly (optimistic).
- No acknowledged edit is lost. A server crash loses at most about one second of unacknowledged work, and the client resends it.
- All viewers of a board converge to the same state.
- Boards up to 100,000 objects open in a few seconds. Up to 100 active editors and 1,000 viewers on one board.
Out of scope: rich text inside a shape (see the Google Docs chapter), video calls and billing.
Back-of-envelope
- Peak: 1M concurrent editors across 200k live boards, so 5 per board on average.
- Cursors: half of users are moving a pointer at any moment, sampled at 10 Hz. Inbound 1M x 0.5 x 10 = 5M updates/s. With 4 other viewers each, outbound is about 20M messages/s. That is the biggest number in the system, and it never touches disk.
- Durable changes: 5% of editors are dragging or typing at any moment. Intermediate drag positions are ephemeral, and only the batched result per 500 ms window is journaled. If 10% of live boards are dirty in a window, that is 200k x 0.1 / 0.5 s = 40k journal writes/s at peak. Average is about 10k/s.
- Board size: 2,000 objects x 300 B = 600 KB typical, 30 MB for a 100k-object board (about 5 MB compressed). With 50M boards at 300 KB average, snapshots are 15 TB, and three retained versions make 45 TB.
- Hot board: 100 editors at 10 Hz is 1,000 cursor updates/s in. Sent naively to 99 others, that is 99k messages/s from one process. Batched into a 100 ms tick frame, it is 10 frames/s per recipient, or 1,000 frames/s total.
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:
- Operational transformation. Works well for text but transform functions grow with each operation pair, and correctness proofs are hard. Figma rejected it as unnecessarily complex for a tree of objects (Figma).
- A general CRDT (Yjs, Automerge). Merges without any central server and supports true peer-to-peer and long offline sessions. The cost is metadata (tombstones, per-op ids) that you carry for every object, and less freedom to enforce rules. A CRDT cannot easily say "no" to a reparent that creates a loop, because it must converge on whatever arrives.
- Server-ordered, per-property last-writer-wins. The board process sees every change in one order, so there is no merge algorithm. Figma's design keeps the latest value per property and treats a conflict as two clients writing the same property of the same object (Figma).
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.
- Load by region. Partition objects by a coarse spatial grid or by frame. The snapshot stores grid cells separately, and the client asks for the cells under its viewport, then updates as the user pans. Ops for objects outside the viewport are skipped using the client's
viewportmessage. - Keep the server's authority whole. The board process still holds the full map, because validation (cycle checks, permissions) needs the whole tree. Only the wire traffic is sliced.
- Presence is the real fan-out problem in a big room. Tick-batch it, drop cursors outside the viewport, lower the cursor rate as viewer count grows, and make followers of one presenter share one stream.
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:
- Batch the journal. Clients may send updates many times a second but the log does not need that granularity. Batches carry a start and end sequence number (call them
seq_startandseq_end), and replay is safe because last-writer-wins makes the final state atseq_endcorrect even if the snapshot sits inside the batch (Figma). - One writer. Two processes opening the same board would write conflicting histories. A lock row plus conditional appends makes the second one fail rather than corrupt the log (Figma).
- Detect holes. Replay must see seq n+1 after n. A gap means a lost batch, and recovery must stop and alert rather than load a wrong board. The practice problem tests this.
- Offline edits. The simple model: the client records its local changes, and on reconnect downloads a fresh copy and reapplies those changes on top. That is the design Figma describes (Figma). Reapplied edits win over what others did meanwhile for the same property, which is predictable and sometimes rude. Show the user a notice if a long offline session overlapped with remote edits.
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
ackguarantees ordering, not durability. Durability follows within the batch interval (target under 1 s). The client keeps unacknowledged ops and resends them with the samecidafter a reconnect, and the server drops duplicates bycid. - 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 = Pon objectO, walk fromPup throughparentlinks. If you reachO, 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
- People are sensitive to lag of about 100 ms in what they see, so changes apply locally first and the server only orders them.
- Orderings that disagree are the root of conflicts, so one owner per board creates a single order and removes the need for merge algorithms.
- 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.
- 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.
- Cursor traffic scales with the square of viewers, so presence is tick-batched, filtered by viewport and kept off disk.
Failure modes
- Board process crashes. The router sees the lease expire, starts a new owner, which loads the snapshot, replays the journal tail (under a second of loss), and clients reconnect with
last_seqand resend pending ops. Duplicates are dropped bycid. - Split brain (two owners). Conditional journal writes under a lease fence the old owner. The old owner must stop acknowledging when a write fails, or clients will see acks for ops that never become durable.
- Journal gap or corrupt batch. Recovery stops at the hole, alerts and falls back to the last snapshot that predates it, marking the board "needs review". Never load a partial board silently.
- Hot board (a webinar with 1,000 viewers). Cap editors, move viewers to relay processes, drop cursor rate with load and show cursors only for the presenter and recent editors.
- Deploy. Closing every board at once makes a checkpoint storm. With a journal, close connections, wait for the journal flush (under one second at p99 for Figma, per Figma) and let clients reconnect with random jitter so they do not all return at once.
In the wild
- Figma, 2019: multiplayer without OT. Figma's server is the authority: it holds the document, keeps the latest value per property and rejects reparenting that would form a cycle. Offline clients reload a fresh copy and reapply their edits. The team chose this over OT because it was simpler to build and reason about (source).
- Figma, 2022: a write-ahead log for multiplayer. Checkpoints every 30 to 60 seconds left a window for loss and caused write spikes at each deploy. A DynamoDB journal with sequence numbers, batching and lock-guarded appends cut that, and 95% of edits were saved within 600 ms (source).
- Notion, 2025: offline mode with a CRDT. Pages marked available offline migrate to a CRDT data model for conflict resolution, because a client may be away long enough that server-ordered writes cannot reconcile. The blog also describes a persistent storage layer, evolved from a SQLite cache, that records why each page is available offline (source).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Server-ordered per-property LWW | Simple, can enforce rules (cycles, permissions), small metadata | Needs a live owner per board, offline merges are last-writer | Online-first tools, object-based canvases |
| General CRDT (Yjs, Automerge) | Peer-to-peer, long offline, no central orderer | Tombstones and ids grow, harder to enforce invariants | Local-first products, rich text inside shapes |
| Operational transformation | Compact steady state, good for text | Complex transform functions, needs a central server | Long text documents |
| Journal plus snapshots | Bounded loss, fast deploys | Two stores to keep consistent, gap handling | Memory-resident state |
| Viewport-sliced loading | Fast open, less bandwidth | More protocol, harder cursor and selection logic | Large infinite canvases |
Leadership lens
- Build first: one board process, WebSocket, per-property LWW, snapshot every minute, and local-first drawing with optimistic apply. Add the journal before you promise "no lost work", and add viewport loading when the p95 board crosses a few thousand objects.
- Questions for design review: What is the unit of conflict? What happens when two processes think they own a board? What is the recovery time for a 100k-object board? Which data is allowed to be lost?
- Cost drivers: connection memory, presence fan-out bandwidth (the largest message count), and snapshot storage. Journal writes are small by comparison once batched.
Check yourself
Two users drag the same rectangle at the same time, one changing
xand the other changingfill. What does each see, and why is it different if both changex?Show answer
Both changes survive, because
xandfillare separate properties. If both changex, the later arrival at the board process wins and everyone converges to that value.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.
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.
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
- Shapiro et al., "A comprehensive study of Convergent and Commutative Replicated Data Types" (INRIA, 2011): the definitions of state-based and operation-based CRDTs.
- Gentle and Kleppmann, "Collaborative Text Editing with Eg-walker" (2024): a recent hybrid of OT and CRDT ideas, relevant for text inside shapes.
- RFC 6455: The WebSocket Protocol: framing, masking and the ping/pong control frames.
Systems docs
- Yjs: Awareness and Presence: presence kept outside the document and cleaned up on disconnect.
- Automerge documentation: a JSON-like CRDT with full history.
- Cloudflare Durable Objects: single-instance addressable objects, the platform tldraw's reference backend uses for rooms.
- Excalidraw: building P2P collaboration: a different design, with end-to-end encrypted rooms and a relay.
Books
- Designing Data-Intensive Applications, Martin Kleppmann, chapter 5 "Replication": multi-leader conflict handling, with collaborative editing as an example.
Other curricula
- donnemartin/system-design-primer: no whiteboard chapter, but its realtime and consistency sections cover the building blocks.
Watch
- CRDTs: The Hard Parts (Martin Kleppmann): what CRDTs solve and where they still struggle, useful for the CRDT versus server-ordering decision.
Free resources
- How Figma's multiplayer technology works: the canonical write-up of a server-ordered, property-level design.
- Making multiplayer more reliable (Figma): journal, checkpoints and lock-guarded writes.
- Realtime editing of ordered sequences (Figma): fractional indexing and its trade-offs.
- tldraw sync: an open-source whiteboard sync library and its room-per-process backend.
- Local-first software (Ink and Switch): the case for CRDT-based, offline-capable apps.
- CRDTs go brrr (Joseph Gentle): how fast CRDT implementations got.