Build a real-time collaborative whiteboard where several people can draw and edit shapes on the same canvas at once, see each other's cursors live, and have everyone's changes stay in sync and survive a refresh.
Presence is the live "who is here and where are they" layer: each person's cursor position, the color assigned to them, and which shapes they currently have selected. You see it as the little colored arrows moving around the canvas with names attached. We treat presence separately from actual drawing for one practical reason: presence is throwaway data. If a cursor update is dropped or arrives late, nothing is lost - the next update overwrites it a few milliseconds later, and when a user disconnects their cursor just disappears. Drawing operations are the opposite: they must be saved and must reach everyone, because a dropped "add shape" means someone's work vanishes. So presence is sent best-effort and never written to disk, while drawing operations go through the durable, reliable path.
WebSocket: a normal web request is one round trip - the browser asks, the server answers, the connection closes. That is fine for loading a page but terrible for a live whiteboard, because the server has no way to push you someone else's drawing as it happens; you would have to keep asking "anything new?" over and over (polling), which is slow and wasteful. A WebSocket is a single connection that stays open in both directions, so the moment another user draws something the server can push it straight to your browser with no asking. That open two-way pipe is what makes the experience feel instant. Optimistic update: when you draw a rectangle, the client paints it on your screen immediately, before the server has confirmed anything. It does not wait for a round trip, so drawing always feels instant even on a slow network. The change is sent to the server in the background; in the rare case the server settles on a different result, the client corrects your screen to match. Eventual consistency: because edits arrive over the network in slightly different orders for different people, two users' screens can disagree for a few milliseconds. Eventual consistency means that once everyone stops editing, every screen ends up showing the exact same board. We accept tiny, momentary disagreement in exchange for the instant, no-waiting feel - the alternative (locking the board so only one person can edit at a time) would defeat the whole point of a shared whiteboard.
The numbers point to an unusual shape: at the storage layer this system is write-heavy, and almost all of those writes are a stream of tiny, sequential drawing operations rather than random row updates. That drives three database decisions. First, board content does not live in a row-per-shape table that we UPDATE on every edit - ~50K random updates/sec would be punishing. Instead each board is a periodic full snapshot in object storage plus a short in-memory op log (Redis) of the changes since that snapshot. Appends are cheap and sequential, and loading a board is one snapshot read plus a few ops, which matches our rare-read pattern. Second, the genuinely relational data - a board's title, its share link, and its view/edit access level - is small and low-traffic (a few writes when a board is created or its link is changed, not per edit). A single PostgreSQL instance handles it comfortably. We deliberately do not add the read replicas and cache layer a read-heavy system leans on, because reads here are few; the pressure is on absorbing writes, not serving reads. Third and most important, the sticky-by-board gateway keeps each live board in memory, so the ~50K ops/sec are merged in RAM, appended to a fast in-memory op log (Redis), and broadcast - while only the occasional snapshot is written to durable storage. The relational database never sees per-operation traffic at all. So we size durable storage to hold one snapshot per board and the snapshot write rate, not to serve a high read rate, and we lean on Redis to absorb the high op-append rate. Presence is louder still (~1M msgs/sec), but it is disposable and never touches durable storage, which is why we throttle it and keep it out of the database entirely.
Users editing one board
(browsers)
| WebSocket (kept open both ways)
v
[ Load Balancer ] routes by board_id (sticky)
|
v
[ WebSocket Gateway A ] [ WebSocket Gateway B ] ...
owns boards 1..N owns boards N+1..M
| holds each board's |
| shapes in memory |
| - apply op |
| - merge (LWW per shape) |
| - broadcast to editors |
| |
| per op: append to op log + fan-out
v
[ Redis (in-memory, NOT durable) ]
- op log: recent ops since last snapshot <-- late joiner replays this tail
- presence: cursors/selections (ephemeral, TTL)
- pub/sub: keeps ops flowing across gateways during a failover
(the gateway also, every ~N ops or ~T seconds, writes a full snapshot:)
[ WebSocket Gateway ] --snapshot--> [ Object Storage (durable) ]
one file per board <-- late joiner loads this first
Non-real-time (create board, title, view-only vs editable link):
[ Browser ] --HTTP--> [ Board Service ] --> [ PostgreSQL ]
Join a board:
1) HTTP GET /boards/:id/snapshot -> latest snapshot + recent ops (current picture)
2) open WebSocket; receive live ops + presence from then onSticky session means the load balancer always sends a given board's users to the same gateway server, instead of spreading them randomly. We do this so one server can keep that board's shapes in its own memory and merge edits there directly. Without stickiness, two editors of the same board could land on different servers, and every edit would need a shared database or extra network hop to reach the other person - slower and more complex. We route by board_id (not by user) precisely because the people who need to talk to each other are the people on the same board. The honest tradeoff: holding a board's state in one server's memory makes that server a temporary single point of failure for that board. If it crashes, the in-memory shapes that were not yet snapshotted are at risk. We handle this two ways. First, the board is regularly snapshotted to object storage, so at most the last few seconds of ops are unsaved, and clients still hold those ops locally and re-send them on reconnect. Second, when a gateway dies the load balancer reroutes that board's users to a healthy gateway, which loads the latest snapshot, replays the recent op log, and resumes. Redis pub/sub is the backstop that keeps ops flowing if, mid-failover, a board's editors are briefly split across two servers.
You could store the board as nothing but the full list of every operation ever made, and rebuild the picture by replaying all of them from the beginning. That is clean in theory but gets slow and expensive: a board edited for months could have hundreds of thousands of ops, and a late joiner would have to replay all of them just to see the current picture. The snapshot + op log approach is the practical middle ground, and it is the same idea databases use for recovery. Every so often (say every few hundred ops or every few seconds of activity) we save a snapshot: the complete current state of every shape, as one file. Between snapshots we keep just the short log of ops that have happened since. To load a board you read the latest snapshot, then replay only the handful of ops newer than it - fast and cheap no matter how old the board is. A concrete example: a board has 50,000 lifetime edits. Without snapshots a new joiner replays all 50,000. With a snapshot taken at op 49,980, they load one file representing all 50,000 edits, then replay just the last 20. The op log stays tiny because the snapshot keeps absorbing it.
Database / Storage Schemas
- Boards (PostgreSQL)
board_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL DEFAULT 'Untitled Board',
access_level board_access_enum DEFAULT 'link_edit', -- link_view | link_edit
snapshot_key VARCHAR(512), -- object-storage key for the latest snapshot
snapshot_version BIGINT DEFAULT 0, -- op number the snapshot was taken at
created_at TIMESTAMP DEFAULT NOW(),
last_modified TIMESTAMP DEFAULT NOW()
-- No accounts: anyone with the link can open it; the edit link allows changes.
-- Per-user accounts/roles and private boards are out of scope, so there is no
-- owner_id and no permissions table - access is the board-level link level above.
- Board Snapshot (Object Storage) -- the actual drawing; one file per board
Path: /snapshots/{board_id}/latest.json (overwritten each snapshot)
Content: {
"board_id": "b_7d4f2a1c",
"version": 49980, -- op number this snapshot reflects
"shapes": [
{
"id": "s1", "type": "rectangle",
"x": 100, "y": 100, "w": 200, "h": 150,
"fill": "#4dabf7", "stroke": "#1c7ed6",
"version": 12 -- per-shape version, for last-write-wins
}
]
}
- Op Log (Redis stream per board) -- in-memory tail, only ops SINCE the latest snapshot
Key: "board:{board_id}:ops"
Each entry: {
"version": 49981, -- monotonic op number for the board
"op_type": "add_shape | update_shape | delete_shape",
"shape_id": "s1",
"client_id": "c_123", -- per-session id of the editor (not an account)
"payload": { ...changed fields... },
"base_shape_version": 12 -- shape version the editor started from
}
-- trimmed up to snapshot_version whenever a new snapshot is written
-- in-memory and not the durability guarantee; saved work is protected by the
-- periodic snapshot plus clients re-sending un-acked ops on reconnect
- Presence (Redis, ephemeral) -- cursors/selections, never durable
Key: "board:{board_id}:presence" (hash, short TTL per field)
Field per user: { "x": 425, "y": 380, "color": "#ff6b6b",
"selection": ["s1"], "name": "Alice" }{
"title": "Product Roadmap Q1",
"access_level": "link_edit"
}{
"board_id": "b_7d4f2a1c",
"share_url": "https://excalidraw.example.com/board/b_7d4f2a1c",
"created_at": "2026-01-15T10:30:00Z"
}{
"board_id": "b_7d4f2a1c",
"snapshot": {
"version": 49980,
"shapes": [
{"id": "s1", "type": "rectangle", "x": 100, "y": 100,
"w": 200, "h": 150, "version": 12}
]
},
"ops_since_snapshot": [
{"version": 49981, "op_type": "add_shape",
"shape_id": "s2", "payload": {"type": "ellipse", "x": 400, "y": 220}}
]
}
// Client renders the snapshot, replays ops_since_snapshot on top, THEN
// opens the WebSocket and replays anything newer than the last version it saw.{
"type": "operation",
"op_id": "op_8f2e1d9a",
"op_type": "update_shape",
"shape_id": "s1",
"base_shape_version": 12,
"payload": { "x": 320, "y": 100 }
}{
"type": "operation_ack",
"op_id": "op_8f2e1d9a",
"shape_id": "s1",
"status": "applied",
"new_shape_version": 13
}
// If a concurrent edit to s1 won, status is "superseded" and the message
// includes the winning shape so the client corrects its optimistic guess:
// { "type": "operation_ack", "op_id": "op_8f2e1d9a", "status": "superseded",
// "winning_shape": { "id": "s1", "x": 100, "y": 40, "version": 14 } }{
"type": "operation",
"version": 49982,
"shape_id": "s2",
"op_type": "add_shape",
"client_id": "c_456",
"payload": {"id": "s2", "type": "ellipse", "x": 400, "y": 220, "version": 1}
}
// The board-level "version" lets each client track the last op it has seen, so
// after a brief disconnect it can ask for only the ops newer than that.{
"type": "cursor",
"x": 425, "y": 380,
"selection": ["s1"]
}{
"type": "presence",
"client_id": "c_456",
"name": "Alice",
"color": "#ff6b6b",
"x": 425, "y": 380,
"selection": ["s1"]
}Operation (op): the smallest unit of change we send and store - "add this shape," "move shape s1 to here," "delete shape s2." We sync the board by sending these small ops rather than re-sending the whole drawing on every change, which is what keeps editing fast even on a big board. Per-shape version: a counter attached to each individual shape that bumps by one every time that shape changes. It is how the server settles same-shape conflicts: when your move of s1 arrives, the server checks whether s1 has already moved on past the version you started from, and the last write to land wins. Because the version is per shape and not per board, edits to different shapes never wait on each other. Snapshot: a single file holding the full current state of every shape on the board. It is the fast way to load a board - read one snapshot, replay the few ops since it was taken - instead of replaying the board's entire history. The op log between snapshots stays tiny because each new snapshot absorbs it.