Design a whiteboard like Excalidraw
A browser whiteboard where people sketch hand-drawn shapes, arrows, and text on an endless canvas, alone or together in a live room. The hard parts are a scene model simple enough to merge element by element, drawing thousands of rough shapes at 60 frames per second on Canvas 2D, and end-to-end encrypted collaboration where the server never sees the drawing.
The question
Design the web client for a whiteboard like Excalidraw. People draw rectangles, ellipses, diamonds, arrows, lines, free-hand strokes, text, and images on an infinite canvas, with a hand-drawn look. They select, move, resize, and rotate things, connect shapes with arrows that stay attached, and pan and zoom freely.
The board works with no account: it saves on the device, exports to PNG and SVG, and makes share links. Anyone can start a live session and invite others by link; everyone sees each other's cursors and edits in real time, and the server must not be able to read the drawing. Walk me through the data model, rendering, hit testing, and how edits from several people are merged.
Real-life examples: Excalidraw, tldraw, Miro, FigJam, Microsoft Whiteboard
Requirements: scoping a whiteboard
A whiteboard looks small next to Figma: no components, no auto layout, no plugins. That is exactly why interviewers like it. With fewer features to list, they expect you to go deep on the few things that matter: how a drawing is stored, how it is drawn, how a click finds a shape, and how two people editing the same arrow end up with the same picture. Spend the first five minutes on questions whose answers move those four things.
Ask about size first, because the number of elements decides whether you can redraw everything every frame. Then ask how live collaboration must be and who must not see the data, because that decides the sync design. Then ask about accounts and storage, because a whiteboard with no login is a very different product from one with a team workspace.
| Question | Answer we assume | What it changes |
|---|---|---|
| How big does a board get? | Most boards have 50 to 500 elements. Big ones reach 5,000 to 10,000, including long free-hand strokes with hundreds of points each. | Whether a full redraw fits in a frame, and whether we need culling, caching, and a spatial index. |
| How smooth must pan, zoom, and drag be? | 60 frames per second on a mid-range laptop, so about 16 ms per frame, and under 50 ms from pointer move to pixels on a phone. | Canvas 2D instead of DOM or SVG nodes, and how much work each frame may do. |
| Do people need an account? | No. The board works the moment the page loads and saves on the device. Accounts and cloud workspaces are out of scope. | Storage is local-first: localStorage and IndexedDB, not a server database. |
| How many people share a live session? | Usually 2 to 10. Up to about 50 in a workshop. | How we merge edits, how often we broadcast, and how we throttle cursors. |
| Who must not see the drawing? | Nobody but the people with the link, including our own servers. | End-to-end encryption, with the key kept where the server never receives it. |
| What can people put on the board? | Rectangles, diamonds, ellipses, arrows, lines, free-hand strokes, text, images, and frames. Shapes can hold a text label. | The element types and their fields. |
| What comes out? | PNG, SVG, the clipboard, and a native JSON file that reopens with full editing. | Export reuses the renderer, and exports carry the scene so they can be reopened. |
Functional requirements
- Draw shapes, arrows, lines, free-hand strokes, and text with tools picked by mouse, touch, pen, or keyboard shortcut.
- Select one element by clicking and many by dragging a selection box or shift-clicking; move, resize, rotate, group, duplicate, delete, and change z-order.
- Bind arrows to shapes, so moving a shape drags the arrow ends along with it.
- Edit text in place, both free text and labels inside shapes.
- Pan and zoom an infinite canvas from about 10% to 3000%, with mouse wheel, trackpad pinch, and touch.
- Undo and redo your own changes, also while others are editing.
- Save the board automatically on the device, open and save
.excalidrawfiles, and export PNG and SVG that can be dropped back in to keep editing. - Start a live session, share a link, see other people's cursors and edits within about 100 to 200 ms.
- Create a share link to a frozen snapshot of the board; opening it gives the recipient their own copy, not a live session.
- Keep a personal library of reusable shapes and import public libraries.
Non-functional requirements
- Frame budget: pan, zoom, and drag at 60 fps for boards up to about 5,000 elements. Larger boards may slow down but must stay usable.
- First load: the empty board is usable within about 2 s on a mid-range phone on 4G, so the editor bundle is split and heavy pieces (export, fonts for CJK text, collaboration) load on demand.
- No lost work: a refresh, a crash, or closing the tab loses at most the last second of drawing.
- Privacy: in a live session and in share links, the server stores and forwards only ciphertext.
- Convergence: after everyone stops typing, every participant sees exactly the same elements in the same order.
- Offline: drawing alone works with no network at all; the app is installable as a PWA.
- Accessibility: every tool and action is reachable from the keyboard, with honest limits for the drawing itself.
Out of scope, and why
Say what you are not designing, so the interviewer can push back early. Team workspaces, permissions, and comments are a different product built on top of the same editor. Exporting to PDF and presenting slides are small additions on the export path. A server-side search over boards is impossible by design once the server can't read them, which is a trade-off worth saying out loud: privacy costs you server features.
Check your understanding · Exercise 1
Which answers from the interviewer would change the core design the most (select all that apply)? Select all that apply.
What makes a whiteboard hard
Five traits of this product drive every later decision. Name them before drawing boxes, because each one rules out a tempting simple design.
- It is a picture, not a document. Shapes overlap, rotate, and sit at any position. There is no text flow or grid to lean on, so the browser's layout engine can't help. You own positions, drawing order, and hit testing yourself.
- It is endless. The board has no edges. The user sees a small window onto a huge plane, at any zoom. Every pointer event must be converted from screen pixels to board positions, and everything outside the window should cost nothing.
- It is hand-drawn. A rough rectangle is not four straight lines. It is a few dozen jittery curves, generated from a random seed. Generating them is slow, and if they changed on every frame the shapes would wobble. So the randomness must be stable and the result cached.
- It is live and private. Several people move the same shapes at the same moment, and the server that relays their edits must not be able to read them. That removes the usual answer (the server decides who wins) and pushes merging into the clients.
- It has no account. The board must survive a refresh with no login, so the device is the source of truth. The network is an optional extra for sharing, not the place where the board lives.
Compare this with the Figma guide. Figma has 50,000 layers in a deep tree, a C++ renderer compiled to WebAssembly that draws with WebGL, and property-level merging on a server it controls. Excalidraw keeps everything small and flat on purpose: a flat array of plain objects, Canvas 2D, and a whole element as the unit of merging. A good answer explains why the simpler choices are right for this product, and where they would stop being right.
| Trait | Simple design it rules out | What we do instead |
|---|---|---|
| A picture, not a document | One DOM node or SVG element per shape, let the browser lay out and hit test. | Draw on a canvas and keep our own geometry for hit testing. |
| Endless | Draw the whole board every frame. | Scene and viewport coordinates, culling of off-screen elements. |
| Hand-drawn | Call rough.js on every frame. | A fixed seed per element and a cache of the generated strokes. |
| Live and private | Send edits to a server that orders and merges them. | Encrypted relay, and each client merges with version and versionNonce. |
| No account | Load the board from an API on page load. | localStorage and IndexedDB first, network second. |
Think first
SVG gives you a node per shape, real hit testing with pointer-events, and free accessibility hooks. Why not render the whole board as live SVG?
At 50 shapes, SVG would work fine. The trouble starts in the thousands. A rough rectangle is a path with dozens of curve segments, and a free-hand stroke can be a path with thousands of points. Every one of them is a DOM node with style, layout, and hit-test cost in the browser. Panning means the browser re-rasterizes all of them, and you can't easily skip off-screen ones without adding and removing nodes, which costs more.
Canvas 2D is a single bitmap. You decide what to draw each frame, you can cache each element as an image, and you can skip anything outside the viewport with a cheap box check. You pay for this by writing your own hit testing and accessibility. Excalidraw still uses SVG, but only for export, where a one-time render is exactly what SVG is good at.
The scene model: a flat list of plain elements
Everything else in the design reads or writes the scene, so decide its shape first. The scene is all the elements on the board plus a little app state (scroll position, zoom, selected tool). An element is one thing you drew: a rectangle, an arrow, a text, a stroke.
The naive model is a tree, like the DOM: a group node holds its children, a shape holds its label, an arrow holds references to its endpoints. Trees look tidy until two people edit at once. Alice moves a rectangle into a group while Bob deletes the group. Whose tree is right?
Merging trees means merging moves, which is the hard problem the Figma and Notion guides spend whole chapters on. A whiteboard does not need that power.
Decision How do we store the scene?
What we pick: Pick the flat array of plain objects. It is what Excalidraw does, and it makes every later piece simpler: the renderer loops over an array, storage writes JSON, and collaboration compares one number per element. The cost is coarse merging (a whole element wins, not a field), which is acceptable because two people rarely edit the same shape in the same second. Mention the CRDT option as the upgrade path if long offline sessions become a requirement.
The fields of an element
Excalidraw's element type, in its open-source repo, shows the fields a real whiteboard needs. Group them by job when you explain them; it shows you understand why each exists:
- Identity and kind:
id(a random string),type(rectangle,diamond,ellipse,arrow,line,freedraw,text,image,frame, and a few more). - Geometry:
x,y,width,height, andanglein radians.xandyare the top-left of the unrotated box in scene coordinates; rotation is around the box center. Arrows, lines, and strokes addpoints, a list of positions relative toxandy. - Style:
strokeColor,backgroundColor,fillStyle(hachure,cross-hatch,zigzag,solid),strokeWidth,strokeStyle,roughness,opacity,roundness. - Hand-drawn stability:
seed, a random integer fixed at creation. rough.js uses it, so the same shape gets the same wobble on every render and on every machine. - Sync bookkeeping:
version(goes up by one on every change),versionNonce(a new random integer on every change),updated(a timestamp), andindex, a fractional index string that orders elements for z-order when lists from two clients are merged. - Relations by id:
groupIds(from deepest group to outermost),frameId,boundElements(the arrows and the text label attached to this shape),containerIdon a text label, and on arrowsstartBindingandendBindingpointing at the shapes they connect. - Lifecycle:
isDeleted. Deleting never removes the object; it sets this flag and bumps the version. That deleted object is a tombstone.
boundElements on the shape, startBinding or containerId on the other side), so moving a shape finds its arrows without a scan. The deleted ellipse stays as a tombstone so its delete can be merged.type Element = Readonly<{
id: string;
type: "rectangle" | "ellipse" | "diamond" | "arrow" | "line"
| "freedraw" | "text" | "image" | "frame";
x: number; y: number; width: number; height: number;
angle: number; // radians, around the box center
seed: number; // fixed: same rough.js wobble forever
version: number; // +1 on every change
versionNonce: number; // new random int on every change
index: string | null; // fractional index, for z-order merges
isDeleted: boolean; // tombstone instead of removal
groupIds: readonly string[]; // deepest group first
boundElements: readonly { id: string; type: "arrow" | "text" }[] | null;
updated: number; // epoch ms of last change
}>;
function bump<T extends Element>(el: T, patch: Partial<T>): T {
return {
...el, ...patch,
version: el.version + 1,
versionNonce: randomInt(),
updated: Date.now(),
};
}
Why deletes are tombstones
Picture removing the element instead. Alice deletes a rectangle at version 7. Bob, a moment later, moves it and broadcasts version 8. Alice receives version 8 of an element she no longer has, and it looks like a brand new element: the shape comes back to life.
With a tombstone, Alice still holds the rectangle at version 8 after her delete (delete bumped 7 to 8), so Bob's move is a fair contest between two version 8 edits, decided the same way on both machines. Tombstones also make undo of a delete trivial: flip isDeleted back and bump the version.
Tombstones cost memory and file size, so they are dropped where nobody needs them: when you save a file or export an image, only live elements are written. In a live room they must stay long enough for every peer to see them, or the resurrection bug returns. Excalidraw's app keeps sending a tombstone to the room for one day after the delete (judged by its updated time) and stops after that.
Groups, labels, and bound arrows
Groups are not objects at all. A group is a random id that appears in the groupIds of each member. Nested groups mean longer lists: an element in group B inside group A has groupIds of ["B", "A"].
Clicking selects the outermost group; double-clicking steps one level in. Because membership is stored on the members, two people grouping different shapes never touch the same object.
Bindings are stored on both ends, which is a deliberate duplication. An arrow's endBinding says which shape it ends on and where (in current versions, a fixedPoint given as a fraction of the shape's width and height). The shape's boundElements lists that arrow, so when you drag the shape you can find and update its arrows without scanning the whole scene.
A text label works the same way: the shape lists the text in boundElements, and the text points back with containerId. The price of two-way references is that a merge can leave them disagreeing, so after every remote update the client repairs links that point at missing or deleted elements.
Check your understanding · Exercise 2
Alice and Bob are in the same room. Which statements about this scene model are true (select all that apply)? Select all that apply.
Rendering: two canvases, cached elements, and stable roughness
The naive renderer is one canvas and one function: on every change, clear the canvas, loop over all elements, call rough.js to generate each shape, and draw it. Then draw the selection box and handles on top. It is about 50 lines and works beautifully for a demo with 20 shapes.
Now do the numbers for a 3,000 element board. rough.js turns a rectangle into roughly a dozen Bezier curves, randomly jittered, with hachure fill lines on top; generating that costs on the order of 0.05 to 0.3 ms per shape. A long free-hand stroke can hold 1,000 points that must be turned into an outline polygon. Even at 0.1 ms each, 3,000 elements is 300 ms per frame: about 3 frames per second.
And the worst part: moving the mouse over a shape only changes the hover outline, yet the naive design redraws all 3,000 shapes to show it.
Three separate costs hide in that loop, and each gets its own fix: generating the rough geometry, rasterizing it to pixels, and redrawing things that did not change.
Decision What do we draw with?
What we pick: Canvas 2D with layers. A whiteboard's elements are few enough that a cached bitmap per element plus culling keeps frames well under 16 ms, and Canvas 2D gives rough.js and text a simple target. This is the main contrast with the Figma guide: Figma needs a GPU engine for 50,000 vector layers; a sketching tool does not, and pays far less for it.
Layer 1: separate what changes often
Excalidraw stacks canvases. The static canvas draws the elements of the scene. The interactive canvas sits on top, with a transparent background, and draws the things that change on almost every pointer move: the selection box, resize and rotate handles, hover outlines, snapping guides, and other people's cursors. Recent versions add a third canvas just for the element you are drawing right now, so a shape being dragged out does not force a static redraw.
The win is that a hover or a remote cursor move redraws only the interactive layer, which holds a handful of lines. The static layer redraws only when elements, scroll, or zoom change. Both redraws are scheduled with requestAnimationFrame, so ten pointer events within one frame cause one draw, not ten.
drawImage from its cached bitmap.Layer 2: cache each element as a bitmap
Rough geometry depends only on the element's shape and style, never on where you are scrolled. So the first cache maps an element to its generated rough.js drawable. The second cache goes further: it draws the element once into its own offscreen canvas, sized to the element's box times zoom times devicePixelRatio, plus a little padding for the stroke. Each frame then costs one drawImage per visible element, which is a fast bitmap copy.
Immutable elements make invalidation free. Both caches are a WeakMap keyed by the element object. A change makes a new object with a new version, so the lookup simply misses and the old entry is garbage collected with the old object. Nobody has to remember to clear anything.
const bitmapCache = new WeakMap<Element, { canvas: HTMLCanvasElement; zoom: number }>();
function getBitmap(el: Element, zoom: number, dpr: number, zooming: boolean) {
const hit = bitmapCache.get(el);
// While a pinch is in progress, reuse the old bitmap scaled; refresh after.
if (hit && (hit.zoom === zoom || zooming)) return hit;
const scale = zoom * dpr;
const pad = el.strokeWidth * 2 + 4;
const canvas = document.createElement("canvas");
canvas.width = Math.ceil((el.width + pad * 2) * scale);
canvas.height = Math.ceil((el.height + pad * 2) * scale);
const ctx = canvas.getContext("2d")!;
ctx.scale(scale, scale);
ctx.translate(pad, pad);
drawRough(ctx, el); // rough.js with el.seed: same wobble every time
const entry = { canvas, zoom };
bitmapCache.set(el, entry);
return entry;
}
Two traps. First, a huge element at high zoom would need a gigantic bitmap; browsers cap canvas area, and memory explodes long before that. Excalidraw caps each element canvas at 16,777,216 pixels of area (a 4096 by 4096 square, about the Safari mobile limit) and 32,767 pixels on a side, and lowers the scale when an element would exceed either.
Second, zooming changes every bitmap's ideal resolution. Regenerating all of them on every wheel tick would freeze the page, so during a zoom gesture the old bitmaps are stretched (a bit blurry for a moment) and regenerated once the gesture settles.
Stable roughness with a seed
rough.js draws a hand-drawn look by adding random offsets to every point and curve. If it used Math.random() each time, a shape would jitter every time it is redrawn, and Alice and Bob would see different wobbles for the same rectangle. Passing the element's seed to rough.js makes its random number generator deterministic: the same seed and the same shape give the same strokes on every frame, every device, and in every export. The seed is created once with the element and is part of the synced data.
Sharp on every screen: devicePixelRatio
A canvas is a bitmap. On a screen where one CSS pixel is two physical pixels (window.devicePixelRatio of 2), a canvas sized in CSS pixels is stretched and looks blurry. The fix is to size the backing store in device pixels and scale the drawing context, as below.
The same ratio must multiply the cached element bitmaps, or they will be blurry even when the main canvas is sharp. Listen for changes too: dragging a window to another monitor or zooming the browser changes the ratio, and a matchMedia query on resolution tells you when.
function sizeCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number) {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssW * dpr); // backing store in device pixels
canvas.height = Math.round(cssH * dpr);
canvas.style.width = cssW + "px"; // layout size in CSS pixels
canvas.style.height = cssH + "px";
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixel units
return ctx;
}
Check your understanding · Exercise 3
A user hovers over shapes on a 3,000 element board and the frame rate drops to 10 fps. Which changes would help (select all that apply)? Select all that apply.
The infinite canvas: scene coordinates, zoom, and culling
The board has no edges, but the screen does. So the design keeps two coordinate systems. Scene coordinates are where elements live: a rectangle at x: 1200, y: -300 stays there forever, whatever the user does. Viewport coordinates are CSS pixels inside the canvas element, where pointer events arrive and where pixels are drawn. The viewport is the window between them, described by three numbers in app state: scrollX, scrollY, and zoom.
The naive alternative is to store elements in screen pixels and move every element when the user pans. It breaks immediately: panning becomes a write to every element, each one gets a new version and is broadcast to the room, and two people looking at different parts of the board would fight over positions. Panning and zooming must never touch elements. They change only the viewport, which is personal and never synced.
Two formulas you should write on the board
type Viewport = { scrollX: number; scrollY: number; zoom: number };
// pointer (CSS px inside the canvas) -> scene
const toScene = (px: number, py: number, v: Viewport) => ({
x: px / v.zoom - v.scrollX,
y: py / v.zoom - v.scrollY,
});
// scene -> canvas (CSS px); the renderer applies this to every element
const toViewport = (sx: number, sy: number, v: Viewport) => ({
x: (sx + v.scrollX) * v.zoom,
y: (sy + v.scrollY) * v.zoom,
});
Panning by dragging 100 CSS pixels to the right at zoom 2 adds 100 / 2 = 50 to scrollX: the scene moves 50 scene units, which looks like 100 pixels on screen. Getting this division wrong is the most common bug in canvas apps: panning feels fine at 100% and runs away at 400%.
Zoom around the pointer
If you only change zoom, the board scales around the canvas corner and the thing under the pointer slides away. Users expect the point under the pointer to stay put. Write the condition and solve it: the scene point under pointer px must be the same before and after, so px / z1 - s1 = px / z2 - s2, which gives s2 = s1 + px / z2 - px / z1. Same for y.
Clamp the zoom (Excalidraw allows 10% to 3000%) before solving, or the scroll will jump at the limits.
function zoomAt(v: Viewport, px: number, py: number, nextZoom: number): Viewport {
const z = Math.min(30, Math.max(0.1, nextZoom));
return {
zoom: z,
scrollX: v.scrollX + px / z - px / v.zoom,
scrollY: v.scrollY + py / z - py / v.zoom,
};
}
Input arrives in three ways. A mouse wheel sends wheel events; plain wheel pans, and wheel with Ctrl or Cmd zooms. A trackpad pinch in Chrome, Edge, and Firefox also arrives as wheel with ctrlKey set to true and a small deltaY, so the same handler zooms smoothly by Math.exp(-deltaY / 100) or similar. Safari adds its own gesturechange events with a scale.
On touch screens, track two pointers with Pointer Events, and zoom by the ratio of the distance between them, around their midpoint. Call preventDefault on these wheel events (registered with passive: false) and set touch-action: none on the canvas, or the browser will zoom the whole page instead.
Pick a pointer position, then zoom. The scene point under the pointer stays put.
Culling: off-screen elements cost nothing
The viewport in scene coordinates is the rectangle from (-scrollX, -scrollY) with size canvasWidth / zoom by canvasHeight / zoom. Before drawing, keep only elements whose bounding box intersects it. For a rotated element, use the axis-aligned box around its rotated corners, so a long diagonal arrow that crosses the screen is not dropped. Cache those bounds per element object, like the bitmaps, so each frame does four comparisons per element instead of trigonometry.
Culling turns a 5,000 element board into perhaps 200 drawn elements when zoomed in on one area. When zoomed far out, everything is visible and culling saves nothing, which is exactly when the per-element bitmaps matter most: at 10% zoom each bitmap is tiny, so drawing thousands of them stays cheap.
Think first
In a live room, should a newcomer jump to the same scrollX, scrollY, and zoom as the host?
Not by default. The viewport is personal: two people with different screen sizes and zoom levels need different numbers to see the same area, and forcing one person's viewport on another is disorienting. That is why the viewport lives in app state, not in elements, and is never part of the element sync.
What people do want is an explicit "follow" mode: click a collaborator's avatar to have your view track theirs. Implement it as a separate message carrying the leader's visible scene rectangle, and fit that rectangle into your own canvas. Excalidraw added a follow mode like this. Also, on first open, scroll so the content is centered ("scroll to content") rather than reusing a stored scroll position from a different screen.
Check your understanding · Exercise 4
Zoom is 2, scrollX is 100, scrollY is 0. The user clicks at canvas pixel (300, 200). Which statements are true (select all that apply)? Select all that apply.
Hit testing: what is under the pointer
With DOM nodes, the browser tells you what was clicked. On a canvas you get only a pair of numbers, so you answer the question yourself, on every pointermove for hover and on every pointerdown for selection. The answer must be right (clicking the empty middle of an unfilled rectangle should select what is behind it), and fast enough to run every frame.
The naive approach is a loop from the topmost element down that returns the first element whose bounding box contains the point. It fails in three visible ways.
A diagonal arrow from the top-left to the bottom-right of the screen has a bounding box covering the whole screen, so it steals every click. A rotated rectangle's axis-aligned box includes empty corners. And a transparent rectangle drawn around other shapes, a common way to make a "zone", captures clicks meant for the shapes inside it.
Decision How do we find the element under the pointer?
What we pick: Geometry with two phases. It is what Excalidraw does, and it gives you a tolerance for free, which matters more than people expect: a 1 pixel line is impossible to click without one. Color picking is a fine answer for apps with arbitrary pixel art, not for a vector whiteboard.
The narrow phase, type by type
- Rotation first: rotate the pointer around the element's center by
-angle. Now every shape can be tested as if it were not rotated, so one set of formulas covers all angles. - Filled shapes (rectangle, diamond, ellipse with a non-transparent
backgroundColor): hit if the point is inside the outline, or within the tolerance of it. - Unfilled shapes: hit only near the outline. Measure the distance from the point to each edge (or to the ellipse) and compare with the tolerance. This is what lets you click through the middle of an empty frame-like rectangle.
- Arrows and lines: distance from the point to each segment of the polyline, or to the curve if the line is rounded. A segment test is a projection clamped to the segment's ends.
- Free-hand strokes: distance to the polyline through the stroke's points, compared with half the stroke width plus the tolerance. Long strokes have hundreds of segments, so give each stroke its own cached bounding boxes per chunk of points.
- Text and images: inside the box (after rotation), because people expect to grab text anywhere in its block.
Test against the clean geometry, not the rough strokes. The hand-drawn wobble moves lines by a few pixels; users aim at the shape they meant, and the tolerance absorbs the wobble.
A tolerance that grows when zoomed out
The tolerance must feel constant on screen. Pick it in CSS pixels, say 10 px for a mouse and more for touch, and convert to scene units by dividing by zoom: at zoom 0.25, a 10 px tolerance is 40 scene units. Without that division, thin lines become impossible to click when zoomed out and too easy to hit by accident when zoomed in. Use PointerEvent.pointerType to pick a larger value for touch, where a fingertip covers 40 or more pixels.
function hitTest(scene: Element[], px: number, py: number, v: Viewport, touch: boolean) {
const p = toScene(px, py, v);
const tol = (touch ? 20 : 10) / v.zoom; // constant on screen
const candidates = index.query(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
// topmost first: higher fractional index wins
candidates.sort((a, b) => (a.index! < b.index! ? 1 : -1));
for (const el of candidates) {
if (el.isDeleted || el.locked) continue;
const local = rotateAround(p, centerOf(el), -el.angle);
if (hitShape(el, local, tol)) return el; // per-type math
}
return null;
}
When to add a spatial index
A loop over 2,000 boxes is four comparisons each, well under a tenth of a millisecond. Do not add a spatial index for that. It starts to pay off around 10,000 elements, or when one query must return many results, as with a selection box dragged across a crowded board, or the eraser tool sweeping through strokes.
Two good choices: a uniform grid (buckets of, say, 512 by 512 scene units, each holding the ids of elements that overlap it), which is simple and great when elements are similar in size; or an R-tree (the rbush library is a popular one), which handles mixed sizes, such as a 10,000 unit frame next to tiny dots. Either way, update the index only for elements whose version changed, and query it for culling too.
Check your understanding · Exercise 5
Which hit-testing behaviors would users report as bugs (select all that apply)? Select all that apply.
Tools and interactions: one pointer, many gestures
A whiteboard has one input stream, Pointer Events from a mouse, pen, or finger, and a dozen meanings for it. A press then a drag can draw a rectangle, move a selection, resize from a handle, rotate, draw a selection box, pan with the hand tool, or erase. The naive way is a pile of booleans (isDragging, isResizing, isDrawing) checked in every handler. It breaks the first time two flags are true at once: a resize that starts on a handle sitting on top of another shape, or a second finger landing during a drag.
Model the gesture as a state machine instead: one current state, a fixed set of events (down, move, up, cancel, Escape), and explicit transitions. What a move means is decided once, by the state, not by a chain of if checks. On pointerdown call setPointerCapture, so the drag keeps receiving events when the pointer leaves the canvas, and listen for pointercancel, which the browser sends when it takes over the gesture.
pointermove is decided by the current state, not by a pile of booleans. Every gesture ends in exactly one commit or one cancel.The decision on pointerdown is made from three things: the active tool, what the hit test returns, and whether the point is on a handle of the current selection. With the selection tool: on a resize handle, go to resizing; on the rotate handle, rotating; on an element, dragging (selecting it first if needed); on empty space, boxSelecting. With a shape tool, go to drawing. Escape from any active state restores the elements saved at pointerdown and returns to idle.
Selection and multi-select
Selection is local app state, a set of selected ids, not a field on elements. If it were on elements, selecting would bump versions and broadcast, and Alice selecting a shape would deselect it for Bob.
Shift-click toggles an id. A selection box selects elements fully inside it; testing "fully inside" against cached bounds is cheap, and the spatial index from the hit-testing chapter keeps it fast on big boards. Clicking a grouped element selects every element sharing its outermost group id. Collaborators can still see what you have selected: send your selected ids with your cursor, as presence, and draw them in your color on their interactive canvas.
Resize, rotate, and snapping
Resizing a rotated shape is where bugs live. Do it in the shape's own frame: rotate the pointer by -angle around the center, compute the new box as if the shape were not rotated, then fix x and y so the opposite handle stays in place on screen. Shift keeps the aspect ratio; Alt resizes from the center.
Rotation is atan2 of the pointer relative to the center, with Shift snapping to 15 degree steps. For a multi-selection, resize and rotate the group's common box and apply the same transform to each member.
Snapping makes neat diagrams possible. While dragging, collect the edges and centers of nearby elements (from the spatial index, limited to the viewport), and if the dragged box's edge or center is within a few screen pixels (divided by zoom again) of one of them, nudge it there and draw a guide line on the interactive canvas. A grid mode is simpler: round positions to multiples of the grid size.
Arrows that stay attached
When an arrow's end is released over a shape (or close to its outline), the arrow binds to it: the arrow gets an endBinding with the shape id and the attachment point, and the shape gets the arrow in its boundElements. From then on, any change to the shape's position, size, or angle runs a fix-up step: for each bound arrow listed on the shape, recompute the bound endpoint from the shape's new geometry and write a new arrow version.
That fix-up is why both sides of the link are stored: without boundElements, moving a shape would mean scanning every arrow on the board.
Edge cases to name: deleting a shape must clear the bindings on its arrows, or they point at a tombstone. Duplicating a shape with its arrows must remap the ids in both directions. And in a live room, Alice moving a shape while Bob drags the arrow's free end both write the arrow; whole-element merging means one of them wins and the arrow end may jump once. That is acceptable here; the reconcile rule in the collaboration chapter decides the winner.
boundElements. Both get new versions and are broadcast together.Text editing with a textarea overlay
Writing a text editor inside a canvas means re-implementing a caret, selection, IME input for Chinese and Japanese, spellcheck, copy and paste, and screen reader support. Don't. Excalidraw's approach is to place a real textarea over the canvas exactly where the text element is: its position is the element's viewport coordinates, its font size is fontSize * zoom, and it is rotated with a CSS transform by the element's angle. While it is open, the canvas skips drawing that text element, so the user sees one copy.
On each input event the element's text and measured size are updated (so bound shapes can grow), and on blur or Escape the edit is committed; empty text is deleted.
Free-hand strokes with pressure and smoothing
Free-hand drawing needs every point the pen produced. Browsers coalesce high-rate pointer moves into one event per frame, so read event.getCoalescedEvents() where the browser supports it to get the in-between points, and store each point relative to the element's x and y. For a pen, PointerEvent.pressure gives a value from 0 to 1 per point; for a mouse, pressure is a constant, so simulate it from speed (fast strokes thinner).
Excalidraw stores points, pressures, and a simulatePressure flag, and turns them into an outline with the open-source perfect-freehand library, whose getStroke function takes the points plus options such as size, thinning, smoothing, and streamline and returns a polygon to fill. Smoothing happens at render time, so the raw points stay in the file and can be re-rendered better later.
Check your understanding · Exercise 6
Which design choices for interactions are sound (select all that apply)? Select all that apply.
Collaboration: encrypted rooms and element-level merging
The usual answer for real-time editing is a smart server: clients send operations, the server orders them, transforms or merges them, stores the result, and sends it out. The Google Docs guide does this with operational transformation; the Figma guide does it with a server that owns property-level last-writer-wins. Both need a server that can read the document. Our requirement says it must not.
So the server becomes a relay: it knows which sockets are in which room and forwards opaque bytes between them. It can't merge, can't validate, and can't store a readable copy. Every client must therefore be able to merge any update on its own and reach the same result as every other client, in any order of arrival.
The Excalidraw team described exactly this "pseudo-P2P" model in their blog post on building collaboration: a central server relays end-to-end encrypted messages to all the peers in a room, without central coordination. The in-depth lesson on collaboration basics covers the general trade-offs; here we go through the specific design.
Decision How do clients merge concurrent edits without a smart server?
What we pick: Element-level last-writer-wins. It is what Excalidraw ships, and it fits the product: people in a whiteboard session mostly work on different shapes, the rare collision costs one shape a jump, and the whole merge is a dozen lines that run on every client. Mention that a CRDT (see the CRDTs lesson) is the move if offline editing for hours becomes a requirement.
The reconcile rule
Every change bumps version by one and picks a fresh random versionNonce. When a remote element arrives, compare it with the local element of the same id. Keep the local one if the user is editing it right now (typing in it, resizing it, or still drawing it), if its version is higher, or if versions tie and the local nonce is lower or equal. Otherwise take the remote one.
An id that exists on only one side is simply kept. Then sort by the fractional index to restore a common z-order.
function keepLocal(local: Element | undefined, remote: Element, editingId: string | null) {
if (!local) return false;
if (local.id === editingId) return true; // never yank what I'm editing
if (local.version !== remote.version) return local.version > remote.version;
return local.versionNonce <= remote.versionNonce; // tie: lower nonce wins
}
function reconcile(local: Element[], remote: Element[], editingId: string | null) {
const byId = new Map(local.map((e) => [e.id, e]));
const out = new Map<string, Element>();
for (const r of remote) {
const l = byId.get(r.id);
out.set(r.id, keepLocal(l, r, editingId) ? l! : r);
}
for (const l of local) if (!out.has(l.id)) out.set(l.id, l);
return [...out.values()].sort(byFractionalIndex);
}
Why a random nonce instead of a client id or a timestamp for the tie-break? Clocks on two laptops disagree by seconds, so timestamps would let a wrong clock always win. A fixed client id would make the same person win every tie. A fresh random number per change is fair, needs no coordination, and gives every receiver the same answer.
Why the "currently editing" exception? Without it, Bob's update could swap out the text element Alice is typing into and her textarea would jump. Her local version wins until she commits; after that her final version, with a higher number, goes out to everyone.
Both start with r1 at version 4. Edit on either side, then deliver the messages in any order.
Z-order needs its own care. If order were just array position, Alice moving a shape to the front while Bob adds a new shape would produce different arrays on each side. Each element carries a fractional index, a string that sorts between its neighbours (for example a0, a0V, a1).
Bringing a shape forward gives it a new index between two others, which is a normal versioned change of one element. Excalidraw adopted this in 2024, using the fractional-indexing library, so merged lists sort to the same order everywhere.
End-to-end encryption with the key in the fragment
The trick that makes this work without accounts is the URL fragment, the part after #. Browsers never send it to the server in the HTTP request; only JavaScript on the page can read it from location.hash.
Excalidraw's own blog post on end-to-end encryption explains this choice. A room link looks like https://excalidraw.com/#room=ROOM_ID,ROOM_KEY. The room id tells the relay which room to join; the key never leaves the browsers that have the link.
- Starting a session: generate a room id and an AES-GCM key with
crypto.subtle.generateKey, export the key as a short base64url string, and put both in the fragment. - Sending: serialize the changed elements to JSON, encrypt with AES-GCM and a fresh random 12-byte IV per message, and emit the ciphertext and IV to the relay. Never reuse an IV with the same key: with GCM that leaks data and breaks authentication.
- Receiving: decrypt with the key from the fragment. GCM also authenticates, so a tampered or wrong-key message fails to decrypt instead of producing garbage.
- Joining: the relay tells existing members a new user arrived; one of them sends the full scene, encrypted, so the newcomer catches up without the server ever holding a readable copy.
What to send, and how often
Track the last version you sent for each element id, and on each change send only the elements whose version moved past it. A drag of five shapes then sends five small elements per update, about 1 to 2 KB encrypted. Excalidraw sends these from the editor's change callback, so a drag produces roughly one update per rendered frame, and it also broadcasts the full scene at most every 20 seconds as a safety net against dropped messages.
Updates go over a WebSocket via Socket.IO, whose relay server Excalidraw publishes as excalidraw-room. The relay offers two broadcasts: a reliable one for scene changes and a volatile one for cursor positions, where only the latest value matters. Socket.IO drops a volatile message instead of buffering it when the connection is not ready. Event names are listed in the chapter on data model and interfaces.
Cursors follow the presence pattern from the presence-and-cursors lesson: send your pointer in scene coordinates (so it lands in the right place at any zoom), your selected ids, your name, and an idle or away state. Excalidraw throttles these to one every 33 ms, about 30 updates per second. Each receiver can interpolate between positions so a cursor glides instead of hopping, and draws it on the interactive canvas so cursors never cause a static redraw.
Check your understanding · Exercise 7
Alice and Bob both edit the same rectangle starting from version 4. Alice makes it red (her new version 5, nonce 812). Bob moves it (his new version 5, nonce 233). Which statements are true (select all that apply)? Select all that apply.
Undo and redo when other people are editing
Single-player undo is a classic: keep a stack of past scenes, and undo pops the stack and restores the previous scene. Snapshots are easy because elements are immutable; two scenes share every unchanged element object, so a snapshot costs one array of pointers.
Now add Bob. Alice draws a circle; Bob, in the same room, writes a title; Alice presses Ctrl+Z. Snapshot undo restores the scene from before Alice's circle, which also deletes Bob's title, and broadcasts that. Bob watches his work vanish because of someone else's keystroke.
Excalidraw's first multiplayer release simply cleared the undo stack whenever an update arrived from a peer, which avoided the bug at the cost of making undo nearly useless in a session. In 2024 it shipped a proper multiplayer undo built on the idea below.
Decision What does an undo entry hold?
What we pick: Deltas of your own changes. Record an entry only for changes made by this user's actions, never for updates that arrived from the network. Undo then means "reverse what I did", which is what people expect in any shared editor.
x and y onto the current circle as a new version, so Bob's color and title survive.How an undo is applied
- Capture: when a local action finishes (on
pointerupafter a drag, on commit after typing, after a paste), diff the elements it touched and push one entry: for each id, the changed fields' old and new values. Remote updates go through reconcile with a flag that says "do not record". Excalidraw's API makes this explicit with an option on scene updates that says whether to capture the change for undo now, later, or never. - Coalesce: one gesture is one entry. A drag that fires 120
pointermoveevents is one undo step, not 120. Typing a word is one step. - Undo: pop the entry, read the current version of each element, write the old field values onto it, and save it as a new version with a fresh nonce. Because it is a new, higher version, the undo itself wins against the remote copies and is broadcast like any other edit. Push the reversed entry onto the redo stack.
- Skip no-ops: if Bob already moved the circle back to where it was, or deleted it, applying the entry changes nothing visible. Skip it and undo the next entry, so one Ctrl+Z always does something the user can see.
- Clear redo: any new local action clears the redo stack, as in every editor.
Conflicts are resolved by field, not by element. If Alice moved the circle and Bob later recolored it, Alice's undo restores only x and y, so the circle goes back but stays Bob's color.
If Bob deleted the circle, there is a choice. Restoring it would bring back something Bob removed on purpose, which surprises him; skipping the entry surprises Alice less. A reasonable rule is to skip entries whose element was deleted by someone else. Either way, write the rule down: the interviewer wants to see that you thought about it.
Think first
Alice moves a rectangle with two arrows bound to it. What must her single undo entry contain?
Three elements: the rectangle's old and new x and y, and each arrow's old and new endpoint data (its points and, if the end moved, its binding). The arrows changed as a side effect of the move, but they were changed by Alice's action, so they belong to the same entry.
If the entry held only the rectangle, undo would move the rectangle back and the arrow fix-up would run again, which mostly works, but any manual bends Alice made to the arrows would be lost, and the arrows would get versions that do not match a clean reversal. Capturing every element the action touched keeps undo exact.
Check your understanding · Exercise 8
Alice moves a shape. Then Bob recolors the same shape. Then Alice presses undo. With delta-based multiplayer undo, which statements are true (select all that apply)? Select all that apply.
Local-first storage, image files, and share links
With no account, the device is the source of truth. The board must be on screen before any network request finishes, survive a refresh, and work on a plane. The network is used only to share. This is the local-first pattern from the offline-first lesson, in its purest form: there is no server copy to fall back on, so a storage bug is a data-loss bug.
Decision Where does the board live on the device?
What we pick: Split by data type. Excalidraw keeps the scene and app state in localStorage (small, synchronous, instant first paint) and image files in IndexedDB. That is a good default. For our larger target boards, where a 5,000 element scene with long strokes can approach a few megabytes of JSON, move the scene into IndexedDB too and keep only a tiny pointer and the viewport in localStorage.
When to save
Saving on every change would serialize the whole scene 60 times a second during a drag. Instead, debounce: save about 300 ms after the last change, which is what Excalidraw does. That meets the "lose at most a second" requirement in normal use.
For the crash and close cases, also flush on visibilitychange when the page becomes hidden and on pagehide; these fire reliably on mobile when the user switches apps, unlike beforeunload. Write only the scene and the user-facing app state (tool, colors, zoom), never transient state like the current drag.
Two tabs of the same board can overwrite each other: tab A saves, then tab B saves its older copy on top. Handle it like collaboration in miniature. When a tab becomes visible, re-read storage and reconcile by version; while visible, listen to storage events or a BroadcastChannel to pick up the other tab's saves. The multi-tab lesson on cross-tab sync covers the general tools.
Images are stored apart from elements
An image element holds only a fileId plus its geometry. The bytes live in a separate map of files (id, MIME type, data, created time).
Keeping them apart pays off three ways. Elements stay small, so the scene JSON can be saved and broadcast often. The same image pasted ten times is stored once. And in a live room, a file is uploaded once and fetched once per peer, instead of riding along with every move of the image element.
Downscale big photos before storing (a 12 megapixel phone photo does not need to be 4,000 pixels wide on a whiteboard), as the image optimization lesson explains. In a live room, files are encrypted with the room key and uploaded to object storage; peers download and decrypt them by id.
# is never sent in HTTP requests, so the Share API stores and serves bytes it can't read. Anyone with the full link can read the board: that is the trade-off.Snapshot share links
A share link is a snapshot, not a session. The client serializes the scene, compresses it (compression must come before encryption, since ciphertext does not compress), encrypts it with a fresh AES-GCM key, and uploads the ciphertext to a small storage API, which returns an id.
The link is https://excalidraw.com/#json=ID,KEY: the id in the fragment tells the client what to download; the key in the fragment decrypts it. The server stores a blob it cannot read, which is the flow Excalidraw's end-to-end encryption post describes. The recipient gets an editable copy, and their edits never change the stored snapshot. If their local board is not empty, opening the link first asks whether to replace it, because silently overwriting someone's drawing with a link they clicked is a data-loss bug.
Check your understanding · Exercise 9
Which storage decisions are sound for a no-account whiteboard (select all that apply)? Select all that apply.
Export, clipboard, and libraries
People don't keep a whiteboard to themselves: they paste it into docs, slides, and tickets. The naive export takes the visible canvas and calls canvas.toBlob(). It captures only what is on screen, at screen resolution, with selection handles and cursors baked in, and the image is a dead end: you can't edit it later.
Export is a second render, not a screenshot
Export runs the same element renderer into a fresh offscreen canvas sized to the bounds of the selected or all non-deleted elements, plus padding, at a chosen scale (1x, 2x, 3x), with no interactive layer. Because every shape's wobble comes from its seed, the export matches the screen exactly. For SVG, rough.js can generate the same strokes as SVG paths from the same seed, so canvas and SVG agree.
Text needs one extra step for SVG: the hand-drawn fonts must travel with the file, so embed them as @font-face data, ideally subset to the characters actually used, or the SVG will show a fallback font on machines without them.
Images that reopen as boards
The clever part: a PNG or SVG exported with "embed scene" on carries the full scene JSON inside it. PNG files are a sequence of chunks, and a tEXt chunk can hold any keyword and text without changing the pixels; Excalidraw writes the scene there under its MIME type, application/vnd.excalidraw+json. An SVG can hold the same data, base64 encoded, inside its metadata.
Drop either file back onto the board, and the app looks for that chunk first: if it is there, you get editable elements, not a flat picture. It costs a few KB per image and turns every exported diagram in a wiki into a source file.
application/vnd.excalidraw+json keyword carries the scene. Dropping the image back gives editable elements, not a flat picture.Clipboard in and out
- Copy elements: write the selected elements as JSON text with a marker type, so pasting into another board (or another tab) recreates them. Paste assigns new ids, remaps
groupIdsand bindings to the new ids, and places the result at the pointer. - Copy as PNG:
navigator.clipboard.write([new ClipboardItem({ "image/png": blobPromise })]). Pass a promise, created synchronously in the click handler, because Safari only allows clipboard writes that start inside the user gesture, and rendering the PNG takes longer than that. - Copy as SVG: write the SVG markup as
text/plain, which design tools and editors accept. - Paste from outside: read the
pasteevent'sclipboardData. Files become image elements (stored byfileId), plain text becomes a text element, and a URL can become a link or an embed.
Libraries of reusable shapes
A library item is just a named list of elements, like a mini scene. The personal library is stored on the device, separate from any board, so it follows the user across boards. It exports as a .excalidrawlib JSON file with type, version, source, and a list of libraryItems.
Public libraries (icons for cloud architecture, UML shapes, and so on) live on a community site and are installed by link. Inserting an item is the same as a paste: clone with new ids, remap internal references, and position at the drop point. Because items are plain elements, they need no new rendering or sync code at all, which is a nice payoff of the flat scene model.
Check your understanding · Exercise 10
Which statements about export and paste are true (select all that apply)? Select all that apply.
Architecture: the pieces and how data flows
With the hard parts decided, put the pieces in boxes. The guiding rule: the scene is the single source of truth, and everything else either changes it through one door or reads it. Tools, undo, collaboration, storage, and paste all write through the same updateScene call; the renderer, hit tester, storage, and sync layer all read from the same immutable element list. One door means one place to bump versions, record undo, schedule a render, and mark elements for broadcast.
updateScene. Everything else reads from it. The servers on the right only ever see ciphertext.| Component | Responsibility | Why the boundary is here |
|---|---|---|
| UI shell (React) | Toolbar, style panel, menus, dialogs, library sidebar, collaborator avatars. | Ordinary DOM: accessible, themable, and rarely re-rendered. It reads app state, never elements one by one. |
| Pointer controller | Turns Pointer Events, keyboard, wheel, and touch into gestures with the state machine; calls the hit tester; produces scene updates. | All input meaning in one place, so tools can't fight over events. |
| Scene store | The element list, a map by id, versions, fractional indices, and the updateScene door. | One owner for the data every other piece reads. |
| App state | Viewport (scrollX, scrollY, zoom), active tool, selection, current style, editing ids. | Personal and never synced; keeps elements free of per-user state. |
| Renderer | Static, interactive, and new-element canvases; shape and bitmap caches; culling. | Pure reader of scene plus app state; easy to reuse for export. |
| Hit tester and spatial index | Point and box queries with tolerance. | Shared by selection, hover, eraser, snapping, and binding. |
| History | Undo and redo stacks of local deltas. | Sees only local changes, which is the rule that makes multiplayer undo work. |
| Collab client | Room join, encrypt and decrypt, broadcast changed elements, reconcile, presence. | Optional and lazy-loaded; the editor works without it. |
| Persistence | Debounced saves to localStorage and IndexedDB, cross-tab reconcile, file store. | The device is the source of truth; network is secondary. |
| Export and files | PNG, SVG, clipboard, .excalidraw and .excalidrawlib files, scene embedding. | Loaded on demand; heavy font and encoding code stays out of the first load. |
React renders the UI shell, not the board. A whiteboard frame must not go through React reconciliation for every element; React would diff thousands of virtual nodes to produce one canvas. The canvas component is a thin wrapper that hands the scene and app state to the renderer and lets it draw imperatively. Excalidraw itself is a React component (@excalidraw/excalidraw on npm) built exactly this way, which is also why it can be embedded in other apps such as note-taking tools.
Data flow, end to end
Follow one drag from input to every other screen. The pointer controller turns pointermove into new x and y for the selected elements and calls updateScene. The scene store creates new element objects with bumped versions and notifies subscribers. The renderer schedules a frame.
The history module waits for pointerup to record one entry. Persistence restarts its 300 ms debounce. The collab client collects elements whose version is newer than what it last sent, encrypts them, and emits them through the relay. On Bob's side, the collab client decrypts, reconciles, and calls the same updateScene, flagged as remote so history ignores it, and Bob's renderer draws.
Note what never crosses the network: the viewport, the selection as state, cached bitmaps, the undo stacks, and the room key. And note what the server holds: room membership by socket id, ciphertext in transit, and encrypted blobs for share links, room snapshots, and image files. That list is your privacy story in one sentence.
Check your understanding · Exercise 11
Which architecture choices keep this design simple and fast (select all that apply)? Select all that apply.
Data model and interfaces: state, file format, and relay messages
The scene-model chapter defined elements. A full answer also says where every other piece of state lives, and why. The rule of thumb: anything shared goes in elements and is versioned; anything personal goes in app state and is never synced; anything big and binary goes in a file store keyed by id; anything derived (bounds, bitmaps, the spatial index) lives in caches that can be thrown away and rebuilt.
| State | Lives in | Synced? | Persisted? |
|---|---|---|---|
| Elements, including tombstones | Scene store (immutable array plus map by id) | Yes, changed elements only | Yes, without tombstones in files |
Viewport: scrollX, scrollY, zoom | App state | No (only in follow mode) | Yes, locally |
| Active tool, current stroke and fill style | App state | No | Yes, locally |
| Selection and editing ids | App state | As presence only | No |
| Image bytes | File store in IndexedDB, keyed by fileId | Uploaded once, encrypted | Yes |
| Undo and redo stacks | History module, in memory | No | No |
| Collaborators: name, color, cursor, selection, idle state | Collab client, in memory | Yes, as volatile presence | No |
| Room id and key | URL fragment, plus session memory | Never sent to the server | Name only, so you can rejoin |
| Rough shapes, bitmaps, bounds, spatial index | Caches in WeakMaps keyed by element | No | No: rebuilt on load |
| Personal library | Device storage, outside any board | No | Yes |
type AppState = {
scrollX: number; scrollY: number; zoom: number;
activeTool: "selection" | "hand" | "rectangle" | "diamond" | "ellipse"
| "arrow" | "line" | "freedraw" | "text" | "image" | "eraser";
currentStyle: { strokeColor: string; backgroundColor: string; strokeWidth: number };
selectedIds: Record<string, true>;
editingTextId: string | null;
theme: "light" | "dark";
};
type BinaryFile = { id: string; mimeType: string; dataURL: string; created: number };
type Files = Record<string, BinaryFile>; // keyed by fileId
type Collaborator = {
socketId: string; username: string; color: string;
pointer?: { x: number; y: number }; // scene coordinates
selectedIds?: string[];
idle?: "active" | "idle" | "away";
};
Two details are worth saying. First, a scene version: a single number, the sum of all element versions, tells cheaply whether anything changed since the last save or broadcast, without diffing. Excalidraw uses this kind of check to skip work when nothing moved.
Second, derived caches are keyed by element object, not by id. An id stays the same across edits; an object does not. Keying by object means a stale cache entry can never be read for a newer version, and the garbage collector cleans up for you.
Think first
Why send collaborator cursors in scene coordinates instead of screen pixels?
Because each person has their own viewport. Bob's pointer at screen pixel (400, 300) means a different board position for Alice, who is scrolled and zoomed differently. In scene coordinates, Alice converts with her own toViewport and the cursor lands exactly on the shape Bob is pointing at.
It also means a cursor outside Alice's viewport can be culled, or shown as an arrow at the screen edge pointing toward Bob, which is a nice touch for big boards.
Three contracts: file, relay, and component
A whiteboard has three contracts worth writing down: the file format that outlives any version of the app, the messages that travel through the relay, and, because boards are often embedded in other products, the props of the editor component. Keep each small and versioned.
The .excalidraw file
The native file is plain JSON with the MIME type application/vnd.excalidraw+json. It holds a type marker and format version, the source app, the live elements, the app state worth keeping (background color, grid), and the files map. Plain JSON was a deliberate choice: it diffs well in git, other tools can generate it, and a file from 2020 still opens because loading runs a restore step that fills in defaults for fields added later (for example index or roundness) and drops unknown junk.
{
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
"elements": [
{ "id": "r1", "type": "rectangle", "x": 100, "y": 80,
"width": 160, "height": 90, "angle": 0, "seed": 1968410350,
"version": 7, "versionNonce": 403124127, "index": "a0",
"isDeleted": false, "groupIds": [], "frameId": null,
"boundElements": [{ "id": "a1", "type": "arrow" }],
"strokeColor": "#1e1e1e", "backgroundColor": "transparent" }
],
"appState": { "viewBackgroundColor": "#ffffff", "gridSize": 20 },
"files": {}
}
Relay messages
The relay speaks a handful of Socket.IO events, and every payload except room membership is ciphertext. Excalidraw's relay, excalidraw-room, is about 150 lines. The server greets each socket with init-room; the client answers join-room; the server replies first-in-room to the first member, or tells the others new-user, and sends everyone room-user-change with the member list.
Scene data goes up as server-broadcast (reliable) or server-volatile-broadcast (cursors), and comes down to the other members as client-broadcast. A few user-follow events support follow mode. Inside the encrypted payload, the client defines its own message types:
// to relay: emit("server-broadcast", roomId, ciphertext, iv)
// from relay: on("client-broadcast", (ciphertext, iv) => ...)
type Payload =
| { type: "SCENE_INIT"; elements: Element[] } // full scene for a newcomer
| { type: "SCENE_UPDATE"; elements: Element[] } // only changed elements
| { type: "MOUSE_LOCATION"; // sent volatile, ~30/s
socketId: string; username: string;
pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up"; selectedIds: string[] }
| { type: "IDLE_STATUS"; socketId: string; username: string;
userState: "active" | "idle" | "away" }
| { type: "USER_VISIBLE_SCENE_BOUNDS"; // follow mode, volatile
socketId: string; sceneBounds: [number, number, number, number] };
Errors are mostly local. A message that fails to decrypt (wrong key, tampering) is dropped and counted. If the socket disconnects, Socket.IO reconnects with backoff and the client joins the room again. The relay announces it with new-user, the others reply with their full scenes, and its own edits catch up through its next updates and the periodic full sync.
Reconcile makes the merge safe however many messages were lost. That is the payoff of a state-based merge: no message log to replay, no sequence numbers to fix.
Share link API
POST /api/v2/post/ (body: compressed, encrypted bytes)
200 OK { "id": "5649116445016064" }
GET /api/v2/5649116445016064
200 OK (the same encrypted bytes)
link: https://excalidraw.com/#json=5649116445016064,KEY
The editor as a component
When the whiteboard is embedded, the host app needs a few props and callbacks, not access to internals. A good surface, close to what @excalidraw/excalidraw offers: initialData (elements, app state, files, or a promise of them), onChange(elements, appState, files) for the host to save, onPointerUpdate for its own presence layer, viewModeEnabled, theme, langCode, and an imperative API handle with updateScene, getSceneElements, addFiles, scrollToContent, and export helpers. Tools like Obsidian plugins and documentation sites build on exactly this kind of surface.
Check your understanding · Exercise 12
Which interface decisions hold up over years of use (select all that apply)? Select all that apply.
Performance with thousands of elements
The earlier chapters each removed one cost. This chapter puts them together against a budget, because an interviewer will ask "what happens at 10,000 elements?" and wants numbers, not a list of buzzwords. The runtime performance lesson covers the browser side (long tasks, the frame pipeline); here we spend the 16.7 ms of a 60 fps frame on a whiteboard.
| Step during a drag | Naive cost at 5,000 elements | Designed cost | What made the difference |
|---|---|---|---|
Handle pointermove, update 5 dragged elements | 2 to 5 ms if the whole array is copied and re-indexed | Under 0.5 ms | New objects only for changed elements; map by id updated in place. |
| Fix up bound arrows | Scan all arrows: 1 to 2 ms | Under 0.1 ms | Follow boundElements on the moved shapes. |
| Cull to the viewport | None: draw everything | About 0.3 ms | Cached bounds per element object; spatial index above 10,000. |
| Draw the static scene | 300 ms or more generating rough shapes | 2 to 6 ms | One drawImage per visible element from the bitmap cache. |
| Draw handles, guides, cursors | Included in the full redraw | Under 1 ms | Separate interactive canvas. |
| Save and broadcast | Serialize the scene every event | Off the frame | 300 ms debounced save; only changed elements broadcast. |
Better still, during a drag the static scene doesn't need to change at all for the elements that are not moving. A further step is to draw the dragged elements on the top layer and leave the static canvas untouched until the drag ends. That keeps a 10,000 element board smooth while you drag one shape. The cost is complexity: z-order is briefly wrong if the dragged shape should be under others, which most users never notice during a drag.
The memory bill for bitmap caches
Caches trade memory for speed, so do the arithmetic. A 200 by 100 shape at zoom 1 on a devicePixelRatio 2 screen is a 400 by 200 bitmap: 320,000 bytes at 4 bytes per pixel. A thousand of those on screen is about 320 MB of canvas memory, which is a lot on a phone.
Two facts keep this in check. Bitmaps scale with zoom, so zoomed out they shrink by the square of the zoom: at 25% they take a sixteenth of the memory. And only drawn elements get bitmaps.
Still, add a cap: keep an LRU (least recently used) list of cached bitmaps and evict beyond a budget such as 200 MB, and drop the cache entirely when the page is hidden. Cap per-element size too, as the area limit in the rendering chapter does.
Free-hand strokes are the heavy elements
A 5 second pen stroke at 120 Hz with coalesced events is 600 points or more. Turning that into a smooth outline is the most expensive single render, and hit testing it is the most expensive single test.
Three tricks: render the stroke in progress incrementally on the new-element canvas instead of recomputing the whole outline each frame; keep per-chunk bounding boxes (every 50 points, say) so hit tests skip most of the stroke; and optionally simplify points that lie almost on a straight line after the stroke ends, which can cut points by half without visible change.
Startup and the main thread
- Split the bundle: the editor core first; collaboration (socket client), export (PNG encoding, font subsetting), the library browser, and rarely used language packs on demand. The code-splitting lesson covers how.
- Fonts: the hand-drawn fonts are
woff2files loaded withfont-display: swap, but text must be re-measured and re-cached once they arrive, or labels overflow their shapes. Listen todocument.fonts.readyand invalidate text bitmaps. - Big files: parsing and restoring a 10 MB scene can block for hundreds of milliseconds. Show the empty board immediately and load in the background, or parse in a worker.
- Measure: track frame times with
requestAnimationFrametimestamps, long tasks and Long Animation Frames withPerformanceObserverin Chromium, and INP for the UI shell. Report element count with each sample so you can see where the curve bends.
Check your understanding · Exercise 13
A user reports that a board with 8,000 elements is smooth when zoomed in but stutters when zoomed far out. What is the most likely reason, and what helps (select all that apply)? Select all that apply.
Accessibility of a canvas app, honestly
Start with the honest part, because interviewers notice when candidates overclaim. A canvas is a bitmap. A screen reader sees one image with whatever label you give it, not the 40 shapes and arrows inside it. A visual diagram tool will never be as accessible as a document, and the goal is to make every control fully usable and give the content a real text form, not to pretend the pixels are accessible.
Split the app into two halves and treat them differently. The UI shell (toolbar, style panel, menus, dialogs, library) is ordinary DOM and must meet WCAG like any other app. The board needs an alternative representation, keyboard ways to do what the pointer does, and announcements for what changes.
The shell: real controls and shortcuts
- The tool picker is a group of real
buttons or a radio group witharia-pressedoraria-checked, each with a visible tooltip that names its shortcut. Excalidraw uses single letters and numbers, for exampleRfor rectangle,Dfor diamond,Ofor ellipse,Afor arrow,Lfor line,Tfor text,Efor eraser, andHfor the hand tool. - Shortcuts must not fire while typing in a text element or a dialog input, and single-letter shortcuts need a way to turn them off (WCAG 2.1.4, Character Key Shortcuts) for speech-input users.
- Dialogs (export, share, help) trap focus, close on Escape, and return focus to the control that opened them, as the focus management lesson describes.
- A help dialog lists every shortcut, so keyboard users can discover them.
The board: keyboard editing and a text alternative
- Keyboard editing: arrow keys nudge the selection (Shift for bigger steps), Enter edits the selected element's text, Delete removes it, Ctrl or Cmd plus D duplicates, and a shortcut cycles selection through elements in z-order so you can reach any element without a pointer.
- Live announcements: an
aria-liveregion says what happened in words: "Rectangle added", "3 elements selected", "Bob joined the session". Keep them short and debounce bursts, or the screen reader will talk over itself during a drag. - A text alternative: render a visually hidden, navigable outline of the scene: each element by type and text, grouped by frame, with connections spelled out ("Arrow from Login to API"). Bound arrows make this possible, because the model already knows what connects to what. This is a design proposal beyond what most whiteboards ship today; say so.
- Text-based input: generating diagrams from text, such as Mermaid syntax, which Excalidraw supports, gives people who can't draw precisely another way in.
- Exports: SVG exports can carry a
titleanddescelement; prompt for alt text when exporting for documents.
Visual and motion details matter too. Selection outlines and remote cursors must keep 3 to 1 contrast against both themes. Respect prefers-reduced-motion by turning off animated scroll-to-content and smooth zoom transitions. Make the touch targets for resize and rotate handles bigger on touch screens: the drawn handle can stay small, but its hit area should be around 44 CSS pixels.
Check your understanding · Exercise 14
Which accessibility claims about this whiteboard are accurate (select all that apply)? Select all that apply.
Running it in the interview
You have about 60 minutes, and this problem rewards depth over breadth. The trap is spending 20 minutes on toolbars and colors. The interviewer chose a whiteboard to hear about coordinates, rendering, hit testing, and merging; get there by minute 10.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: size, latency, privacy, accounts, collaboration size. | Write the assumed numbers: 5,000 elements, 60 fps, 2 to 50 people, server must not read boards, no login. |
| 5 to 12 | Scene model. | The element object with version, versionNonce, seed, isDeleted, groupIds, bindings. Say "flat list, references by id". |
| 12 to 22 | Rendering and the infinite canvas. | Two canvases, bitmap cache, rough.js seed, the two coordinate formulas, zoom around the pointer, culling. |
| 22 to 28 | Hit testing and interactions. | Broad then narrow phase, tolerance divided by zoom; the pointer state machine; text as a textarea overlay. |
| 28 to 42 | Collaboration and undo. | Relay plus key in the fragment; the reconcile rule; tombstones; undo of own deltas only. |
| 42 to 50 | Architecture and storage. | The component diagram with one updateScene door; localStorage and IndexedDB; share links. |
| 50 to 60 | Performance, accessibility, follow-ups. | The frame budget table, bitmap memory, and the honest accessibility story. |
Draw three things on the board, in this order: the element object (as a box of fields), the coordinate diagram with the two formulas, and the architecture. Everything else can be spoken. When you state the reconcile rule, write it as one line: higher version wins, tie goes to the lower nonce, never replace what I'm editing. Interviewers remember a candidate who can write the core rule in one line.
Follow-up questions to expect
Think first
"Two people change the same shape's color and position at the same moment. One change is lost. Is that acceptable, and how would you fix it if not?"
Acceptable for a whiteboard: sessions are short, people mostly work on different shapes, and the loser sees the shape jump and simply does it again. Converging is what matters, and the version and nonce rule guarantees it.
If it stops being acceptable, for example long offline editing, move from element-level to property-level last-writer-wins (a version per field, like Figma), or store elements in a CRDT such as Yjs maps. Both keep the color change and the move. The cost is metadata per property and more complex encryption of updates.
Think first
"A customer has a board with 100,000 elements. What breaks first, and what do you change?"
Zoomed out, drawing 100,000 bitmaps per frame breaks first, then memory for those bitmaps, then the linear scans in hit testing and culling. Fixes in order: a spatial index (R-tree) for culling and hit tests; level of detail when zoomed out (tiles of the whole board rendered at low resolution, regenerated in the background, instead of per-element bitmaps); and loading by region so off-screen areas are not even parsed.
At that point you are building Figma's renderer. Say that the Canvas 2D design fits the stated requirement of about 5,000 elements, and that past roughly 50,000 the honest answer is tile-based rendering, possibly on WebGL, as described in the Figma guide.
Think first
"How do you revoke access for someone who already has the room link?"
You can't revoke a key someone already holds. You rotate: create a new room with a new key, copy the scene into it, and share the new link only with the people who should stay. The old room's ciphertext is useless to anyone without the new key.
For real access control (named users, revocation, audit), you need accounts and a key-distribution system, such as wrapping the room key with each member's public key. That is the line between a link-sharing whiteboard and a team product, and it's a good place to say what Excalidraw+ or Miro add.
Check your understanding · Exercise 15
Which moves show senior-level judgment in this interview (select all that apply)? Select all that apply.
Summary
- Store the board as a flat list of plain, immutable elements with references by id. Every change bumps
versionand draws a new randomversionNonce; deletes areisDeletedtombstones. - Keep two coordinate systems: elements in scene coordinates, the personal viewport as
scrollX,scrollY, andzoom. Panning and zooming never touch elements; zoom around the pointer by solving for the new scroll. - Render on Canvas 2D in layers: a static canvas for elements, an interactive canvas for handles and cursors. Cache each element as a bitmap keyed by the element object, keep rough.js stable with a fixed
seed, cull to the viewport, and scale bydevicePixelRatio. - Hit test in two phases: boxes to find candidates, exact geometry in the element's rotated frame to decide, with a screen-pixel tolerance divided by zoom. Add a spatial index only when boards grow past about 10,000 elements.
- Collaborate through a relay that only forwards ciphertext. The key lives in the URL fragment, which browsers never send to the server. Clients merge per element: higher version wins, a tie goes to the lower nonce, and an element being edited locally is never replaced.
- Undo only your own changes, as field-level deltas applied as new versions; remote updates are never recorded.
- Local-first: the scene in
localStorageor IndexedDB with a debounced save, image bytes in IndexedDB byfileId, share links as encrypted uploads with the key in the fragment, and exports that embed the scene so they reopen as editable boards.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Excalidraw source code The element types, reconcile step, renderer, and collaboration client discussed in this guide.
- Building Excalidraw's P2P Collaboration Feature The Excalidraw team on tombstones, versions, and the versionNonce tie-break.
- End-to-End Encryption in the Browser Excalidraw's post on AES-GCM and keeping the key in the URL fragment.
- excalidraw-room: the collaboration relay The small Socket.IO server that forwards encrypted messages between peers.
- Rough.js The library behind the hand-drawn look, including the seed option.
- perfect-freehand Turns pen points and pressure into a smooth stroke outline.
- Optimizing canvas (MDN) Offscreen pre-rendering, layered canvases, and other Canvas 2D techniques.
- Window.devicePixelRatio (MDN) Sizing a canvas for sharp drawing on high-density screens.
- SubtleCrypto.encrypt() (MDN) AES-GCM in the Web Crypto API, and why an IV must never be reused with the same key.
- PointerEvent.getCoalescedEvents() (MDN) Getting every pen position for smooth free-hand strokes.
Concepts behind it
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.