Design Excalidraw: System Design & Architecture

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.

Scope

In Scope

  • A user can create a board and get a shareable link
  • Anyone with the board link can open it - an editable link lets them draw, a view-only link lets them just watch; each person picks a display name, no account required
  • A user can open a board and draw, move, resize, and delete shapes and text
  • Many users can edit the same board at the same time and see each other's changes within a fraction of a second
  • A user sees other people's live cursors and what they have selected (presence)
  • When two people edit the same shape at the same time, the system settles on one result in a simple, predictable way
  • A user can undo and redo their own recent actions without undoing other people's work
  • When a user opens a board, they load the current state and then receive live updates; refreshing never loses work

Out of Scope

  • Character-level co-editing inside a single text box (a text element here is treated as one whole object with last-write-wins, like any shape; for character-by-character merging see Design Google Docs)
  • Per-user accounts and roles, private (account-gated) boards, and per-shape locking (a board still has a coarse view-only vs editable link, but no per-user permissions)
  • Exporting the board to PNG/SVG, and uploading or embedding images on the canvas
  • Full version history with a browseable timeline and rollback to any past point
  • Organization-level admin, billing, and SSO
  • Native mobile apps (web-first here)
  • Voice/video chat alongside the board

💡 What does "presence" mean, and why is it separate from drawing?

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.

Functional Requirements

  • A user can create a new board and get a unique shareable link
  • A user can open a board via its link and immediately see the current drawing
  • A user can add, move, resize, restyle, and delete shapes and text on the canvas
  • A user's edits are saved automatically as they work; there is no manual save button
  • Every edit a user makes shows up on every other connected user's screen within a fraction of a second
  • A user can set a display name that others see on their live cursor and in the participant list
  • A user sees other collaborators' live cursors and current selections
  • When two users edit the same shape at the same moment, the board converges to one agreed result for everyone
  • A user can undo and redo their own recent actions without undoing other people's work
  • A user who joins late, or reconnects after a network drop, loads the current state and catches up on anything they missed

Non-functional Requirements

  • Edit latency: a local edit appears on the editor's own screen instantly (optimistic), and on other users' screens in well under 100ms within a region
  • Concurrency: support up to ~50 active editors on a single board without lag
  • Scale: support ~10K boards active at the same time during peak hours
  • Consistency: eventual consistency - all clients on a board converge to the same picture once updates stop, even if some arrived out of order
  • Reliability: while connected, drawing operations are reliably delivered and applied in the same order on every client (the owning gateway acks and orders each op, and clients re-send any un-acked ops after a reconnect); presence updates are best-effort and lossy by design
  • Availability: 99.9% for the collaboration service; if real-time sync drops, the client keeps working offline and resyncs on reconnect
  • Durability: effectively no lost drawings - a board's snapshots live in replicated object storage at ~99.999999999% (11 nines) durability
  • Horizontal scalability: add more gateway servers to handle more boards and connections

💡 What do "WebSocket", "optimistic", and "eventual consistency" mean here?

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.

Back-of-the-Envelope Estimates

  • Users: assume ~1M daily active users. At peak ~10K boards are being edited at once - about ~5 editors on a typical board and up to ~50 on the busiest - so roughly ~50K people are drawing at the same time
  • Connections: those ~50K simultaneous editors mean ~50K long-lived WebSocket connections open at peak (one per editor)
  • What a write means here: any change to a board - a drawing operation (appended to the board's in-memory op log and broadcast), a periodic full-board snapshot to durable storage, or a board-metadata change (create, rename, switch the link between view-only and editable). The op stream is the high-volume write; only snapshots and metadata touch durable disk
  • What a read means here: loading a board's saved state - its latest snapshot plus the handful of ops since - when someone opens or rejoins a board, plus the occasional board-metadata lookup
  • Writes: dominated by drawing. An active editor makes ~1 op/sec, so ~50K op-appends/sec at peak, plus a few hundred snapshots/sec (~10K boards snapshotted about once a minute), plus a trickle of board creates. Live cursor traffic is ~1M messages/sec but is never persisted, so it does not count as a write
  • Reads: just board loads. Because an editing session lasts several minutes, only ~tens to low hundreds of boards are opened per second, each load being one snapshot read plus a small op-log read
  • Read:write ratio: at the durable layer this is strongly write-heavy - a flood of small sequential appends against very few reads - the opposite of a read-heavy content system like a URL shortener
  • Board size: a typical board is ~50KB of shapes serialized (a heavy one ~1MB), small enough that a whole board fits in memory and in a single snapshot file

💡 How the read/write picture shapes our database choices

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.

High-level Architecture

  • WebSocket Gateway (sticky by board_id): Holds the open WebSocket connections. A load balancer routes every user of a given board to the same gateway instance so that board's live state lives in one place. This is what lets edits merge in memory instead of hitting the database per edit
  • Collaboration logic (runs on the gateway that owns the board): Keeps the board's current shapes in memory, applies incoming operations, merges concurrent edits with last-write-wins per shape, and broadcasts each accepted op to the other editors on that board
  • Presence channel: A separate lightweight path for cursors and selections. Updates are throttled, broadcast to the board's editors, and never persisted
  • Board (Metadata) Service: A normal HTTP service for non-real-time actions - create a board, set its title, set whether the share link is view-only or editable, and generate the link. Backed by PostgreSQL
  • PostgreSQL: Stores the small, relational board metadata - title, access level (view-only vs editable link), and where the latest snapshot lives. Queried on load and on settings changes, not on every edit
  • Object Storage (S3 style): The durable saved state. A full snapshot of all the board's shapes is written here on a timer (one file per board, overwritten each time), so saved work survives any server restart
  • Redis (in-memory): Holds the per-board op log - the recent operations since the last snapshot - so a late joiner gets snapshot + this short tail, and so ops keep flowing if a board's editors are briefly split across gateways during a failover. Also holds ephemeral presence. It is fast but in-memory; it is not the durability guarantee (the snapshots are)

Data/User Flow Diagram

        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 on

💡 Why "sticky by board_id", and what happens if that server dies?

Sticky 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.

Data Model & Storage

  • Boards (PostgreSQL): board_id, title, access_level (link-viewable / link-editable), snapshot_key (where the latest snapshot lives in object storage), created_at, last_modified. Relational and small, read on load and on settings changes. No accounts: anyone with the link can open it and the edit link allows changes (per-user roles and private boards are out of scope).
  • Board Snapshot (Object Storage): one file per board containing every shape's current state (id, type, position, size, style, and a per-shape version number). This is the durable saved drawing. Overwritten each time we snapshot.
  • Op Log (Redis, per board): the ordered operations applied since the last snapshot - add_shape, update_shape, delete_shape - each with a shape_id and a version. This is an in-memory tail for fast catch-up and cross-gateway fan-out, kept short because the snapshot captures everything older. It is not the durability guarantee: saved work is protected by the periodic snapshot plus clients re-sending any un-acked ops on reconnect.
  • Live board state (in memory on the owning gateway): the working copy the server actually edits and broadcasts from. The snapshot + op log are how we rebuild this after a restart.
  • Presence (Redis, ephemeral, TTL): per-editor cursor x/y, assigned color, display name, and selected shape ids for a board. Never written to durable storage; expires when the editor goes idle or disconnects.

💡 Why snapshot + op log instead of storing every operation forever?

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.

API Design

POST /v1/boards
Create a new board and return its id and shareable link.
GET /v1/boards/:id
Get a board's metadata (title, access level) and confirm the link is valid.
PUT /v1/boards/:id
Update board settings such as title or access level (view-only vs editable link). Requires the edit link.
DELETE /v1/boards/:id
Delete a board and its snapshot. Requires the edit link.
GET /v1/boards/:id/snapshot
Fetch the latest full snapshot plus the recent op log, so the client can render the current picture before going live.
WS /v1/boards/:id/collaborate
The live collaboration connection: send and receive drawing operations and presence (cursors/selections).

Low-level Design

Joining a board (the late-joiner path, step by step):

  • 1) The client calls GET /v1/boards/:id/snapshot, which returns the latest snapshot plus the few ops since it, and renders that - the user now sees the current picture.
  • 2) It opens the WebSocket; the gateway that owns this board streams any ops that landed after that response, which the client replays on top.
  • 3) From then on the client receives live ops and presence.
  • This snapshot-then-replay handshake is why a user who joins an hour-old board, or reconnects after their wifi dropped, lands on the exact current state instead of a blank or stale canvas.

Making an edit (the optimistic path):

  • 1) The user draws or moves a shape; the client applies it locally right away so it feels instant.
  • 2) The client sends the op over the WebSocket with the shape_id and the version of that shape it was editing.
  • 3) The owning gateway applies the op to its in-memory state, bumps that shape's version, appends the op to the op log, and broadcasts it to the other editors.
  • 4) The gateway acks the op back to the sender with the new version.
  • 5) If the server's result differs from what the client guessed (a concurrent edit won), the ack carries the winning state and the client corrects its screen.

Concurrent edits to the SAME shape (the genuinely tricky part):

  • Two users both drag rectangle s1 at the same moment - one left, one up. Both send an update_shape for s1.
  • Each board has a single owning gateway that applies ops in the order it receives them, so there is always one well-defined order and no distributed tie to break. The op it applies second overwrites the first, and that result is broadcast to everyone, so all screens converge on one final position - this is last-write-wins, per shape.
  • Each shape carries its own version, which the gateway uses to spot that your edit started from an out-of-date shape; if another op already won, it tells you (superseded) so you correct your optimistic guess.
  • Because the version is per shape and not a whole-board lock, edits to different shapes never block each other - two people drawing in different corners never conflict at all.
  • Delete beats update: if one user deletes s1 while another moves it, the delete wins and the move is dropped, because moving a shape that no longer exists makes no sense.
  • The loser does not get a "conflicted copy" (that suited files in the Dropbox design); here the action is just a shape position, so silently taking the last write is the least surprising behavior and matches how the real Excalidraw behaves.

Why last-write-wins per shape instead of a full CRDT:

  • A CRDT (conflict-free replicated data type) is a data structure that merges concurrent edits automatically with no central referee, even fully offline. It is powerful but heavier to build and reason about.
  • For whiteboard shapes, the only realistic same-shape conflict is "two people changed this shape's position/size/style at once," and the user-acceptable answer is simply "the last change wins." That does not need a CRDT.
  • Because every board has one owning gateway acting as the referee that decides order, last-write-wins per shape gives us convergence with far less complexity.
  • We would reach for a CRDT if we needed rich offline editing for long stretches, or character-by-character text merging inside a text box - and that text merging is exactly what we put out of scope.

Presence (cursors and selections):

  • Cursor moves are throttled client-side to ~20/sec so we do not flood the network.
  • The gateway broadcasts them to the board's other editors and stores the latest position in Redis with a short TTL.
  • Nothing about presence is durable: a dropped cursor update is corrected by the next one, and a disconnect just lets the TTL expire so the cursor vanishes.
  • This is the high-volume traffic from the estimates (~1M msgs/sec), kept cheap precisely because it is throttled and never written to disk.

Undo / redo without clobbering others:

  • Each client keeps its own local stack of the ops it performed.
  • Undo sends the inverse of the user's own last op (for example, "delete the shape I just added") as a normal operation.
  • Because the stack is per-user, you only ever undo your own actions, not a collaborator's - undoing someone else's drawing out from under them would be surprising and is avoided by design.

Snapshotting and recovery:

  • The owning gateway writes a fresh snapshot (the full shape set) to object storage every few hundred ops or every few seconds of activity, then trims the op log up to that point.
  • On a clean handoff or a crash-and-reroute, the new owning gateway loads the latest snapshot and replays the short remaining op log to rebuild the in-memory state.
  • Worst case after a crash is the few seconds of ops not yet snapshotted; clients still hold those locally and re-send them on reconnect, so they are not truly lost.

Scaling and routing:

  • The load balancer hashes board_id to pick a gateway, so all editors of one board share a server and its in-memory state.
  • Adding gateways spreads boards across more servers; a board is cheap to move because its durable state is just a snapshot file.
  • Redis pub/sub per board is the safety net for the brief windows where a board's editors are split across two servers during a failover, so ops still reach everyone.

Database Types and Schemas

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" }

API Request/Response Payloads

POST /v1/boards
Request
{
  "title": "Product Roadmap Q1",
  "access_level": "link_edit"
}
Response
{
  "board_id": "b_7d4f2a1c",
  "share_url": "https://excalidraw.example.com/board/b_7d4f2a1c",
  "created_at": "2026-01-15T10:30:00Z"
}
GET /v1/boards/:id/snapshot (what a joiner loads first)
Response
{
  "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.
WS send: draw / move a shape (an operation)
Request
{
  "type": "operation",
  "op_id": "op_8f2e1d9a",
  "op_type": "update_shape",
  "shape_id": "s1",
  "base_shape_version": 12,
  "payload": { "x": 320, "y": 100 }
}
Response
{
  "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 } }
WS receive: another user's operation (broadcast)
Response
{
  "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.
WS: cursor update (presence, throttled ~20/sec, not stored)
Request
{
  "type": "cursor",
  "x": 425, "y": 380,
  "selection": ["s1"]
}
Response
{
  "type": "presence",
  "client_id": "c_456",
  "name": "Alice",
  "color": "#ff6b6b",
  "x": 425, "y": 380,
  "selection": ["s1"]
}

💡 Three terms from the flows: operation, per-shape version, and snapshot

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.

Concluding Discussion

Potential Bottlenecks

  • A single very hot board (close to ~50 editors) concentrates load on the one gateway that owns it; mitigate by capping editors per board and giving busy boards more CPU headroom
  • Cursor/presence traffic (~1M msgs/sec) is the dominant load; mitigate with client-side throttling (~20/sec), batching, and never persisting it
  • A late joiner on a huge (~1MB) board has a slower initial load; mitigate by gzipping the snapshot and serving it straight from object storage rather than the live gateway, so big loads never compete with real-time traffic
  • Snapshot writes can spike object-storage traffic for many simultaneously active boards; mitigate by snapshotting on a per-board timer with jitter so writes are spread out, not synchronized

Single Points of Failure

  • The gateway owning a board holds its only live in-memory copy; a crash risks the few unsnapshotted seconds. Mitigated by frequent snapshots, client-held local ops re-sent on reconnect, and rerouting the board to a healthy gateway
  • Redis holds the recent op-log tail, op fan-out, and presence; run it clustered/replicated so a node failing does not lose recent ops or split a board (the durable snapshot plus client re-send remain the ultimate backstop)
  • The Board Service / PostgreSQL is needed to open or create boards; run it with a hot standby for failover so board creation and joining stay available - we do not need read replicas for scale here, since metadata reads are light; this is purely for availability
  • The load balancer doing sticky routing; run it redundantly so routing survives an instance failure

Security Improvements

  • Issue a short-lived, board-scoped token when a client opens a board link, and validate it when the WebSocket connects, so the live connection is tied to a real board and its access level rather than any open socket being able to stream a board
  • Enforce the board's access level on the server for every op - if the link is view-only, incoming drawing ops are rejected, not just hidden in the UI
  • Rate-limit ops and cursor messages per connection to stop a malicious client from flooding a board
  • Sanitize text and shape fields server-side to prevent stored XSS when the board is rendered
  • Use hard-to-guess board ids plus signed, expiring URLs for snapshot downloads, so a board cannot be opened just by guessing ids

Critical Knowledge

If the interviewer asks "why WebSockets instead of polling?":

  • A whiteboard needs the server to push other people's edits to you the instant they happen; with plain HTTP the server cannot start a message, so you would have to keep asking "anything new?" on a timer (polling), which is both laggy and wasteful at 50K clients.
  • A WebSocket is one connection left open in both directions, so edits and cursors flow the moment they occur with no asking.
  • The cost is that open connections hold server memory and must be spread across many gateway servers, which is why we route by board_id and scale the gateway fleet horizontally.

If the interviewer asks "how do you handle two people editing the same shape at once?":

  • We use last-write-wins per shape: each shape carries its own version, the owning gateway picks an order, and the later write overwrites the earlier one, so every screen converges on one result.
  • Edits to different shapes never conflict because the version is per shape, not a whole-board lock - two people in different corners never block each other.
  • Delete beats a concurrent move (you cannot move a shape that no longer exists).
  • This is deliberately simpler than a CRDT or operational transformation, and for "where is this rectangle" the last write is the least surprising answer.

If the interviewer asks "why not use a CRDT like the real Excalidraw / Figma?":

  • A CRDT merges concurrent edits automatically with no central referee, which shines for long offline sessions and character-by-character text merging.
  • But it is heavier to build and reason about, and our design already has one owning gateway per board acting as the referee, so plain last-write-wins per shape gives us convergence with far less complexity.
  • We named character-level text merging as out of scope precisely because that is the case that would actually force a CRDT.
  • The honest tradeoff: our approach leans on that single owning server, whereas a CRDT keeps working peer-to-peer even with no server - we trade some offline power for a much simpler system.

If the interviewer asks "how does a user who joins late or reconnects get the current board?":

  • They load the latest snapshot (the full picture as of some recent op), replay the short op log of changes since that snapshot, and only then start receiving live ops.
  • This snapshot-then-replay is why opening an hour-old board, or recovering from a dropped connection, lands you on the exact current state rather than a blank or stale canvas.
  • It is far cheaper than replaying the board's entire edit history, because each new snapshot absorbs the ops before it and keeps the replay list tiny.

If the interviewer asks "why keep cursors separate from drawing operations?":

  • Cursors are the heaviest traffic by far (~1M msgs/sec vs ~50K drawing ops/sec) but they are disposable - a missed cursor update is fixed by the next one a few ms later.
  • So we throttle cursors (~20/sec per user), broadcast them best-effort, and never write them to disk.
  • Drawing ops are lower-volume but must reach everyone and survive a refresh, so they get the durable, acknowledged path with snapshots and an op log.
  • Treating both the same would either make drawing fragile or make us pay to durably store a million meaningless cursor wiggles a second.