Frontend System Design: Infinite Canvas Whiteboard (Miro / Excalidraw)

interviewNovember 09, 2026ยท 11 min read

"Design a whiteboard like Miro or Excalidraw" is the question interviewers reach for when they want to watch you juggle three hard problems at once: rendering performance at scale, coordinate math under infinite zoom, and real-time presence.

The junior answer treats it as a drawing app: "put shapes in a div, or draw them on a canvas." The senior answer knows a whiteboard is three stacked systems:

  • A rendering pipeline that stays at 60fps with 50,000 shapes in the document โ€” most of them off-screen at any moment
  • A viewport transform that makes infinite pan/zoom boring โ€” one matrix, applied consistently to render, hit-testing, and selection
  • A sync protocol where 20 cursors move at 60fps without crushing the network โ€” and where two people dragging the same sticky note resolves sensibly

Let's design all three.

Requirements โ€” the questions that split junior from senior

Before drawing anything, ask:

  • Document scale? A team retro (50 shapes), a workshop template (5,000), or an org-wide wiki canvas (500,000)? This decides DOM vs canvas vs WebGL โ€” and whether you need a spatial index at all.
  • Real-time collaboration? Live cursors and simultaneous edits (Miro), or async share links with comment threads (FigJam-lite)? Presence changes the architecture more than editing does.
  • Offline tolerance? Do unsynced strokes survive a subway tunnel, and how do they merge on reconnect?
  • Touch semantics? One-finger draws vs one-finger pans โ€” the Excalidraw and Miro split. Two-finger pinch-zoom is universal.
  • What is the unit of conflict? Two people drag the same sticky note in opposite directions โ€” who wins, and does the loser see it happen?
  • Export? PNG, SVG, PDF โ€” this quietly forces your shape model to be data-first, never pixels.

I'll assume the production version: unlimited canvas, up to ~50k shapes per document, live cursors with selection awareness, marquee multi-select, undo/redo, offline-tolerant autosave, PNG export, and desktop-first with two-finger touch support.

The naive answer โ€” and exactly where it dies

The naive answer: render each shape as an absolutely-positioned DOM element, update transform on drag โ€” or, "canvas version," one <canvas> that redraws every shape on every pointer event.

Four ways this dies in production:

  1. DOM dies at thousands of shapes. Five thousand positioned divs with borders, shadows, and border-radius means style, layout, and paint work per frame โ€” on shapes you can't even see. The browser's rendering pipeline is exactly the per-frame cost a whiteboard can't afford. Scrolling a 5k-shape board is a jank demo.
  2. Redraw-everything canvas dies differently. A single canvas that repaints all 50k shapes per pointermove burns the frame budget on invisible geometry. Without culling, frame cost is O(total shapes) no matter what the viewport shows โ€” zoomed into one sticky note, you're still paying for the other 49,999.
  3. Coordinate chaos. Screen pixels, canvas pixels, and world units are three coordinate spaces. Without one consistent transform, zoom-to-cursor drifts, hit-testing lands a pixel off at zoom 3, and strokes connect wrong everywhere except scale 1.0. This is the bug that gets rewritten twice and still isn't right.
  4. Pointer event soup. Draw vs pan vs marquee vs drag-shape vs resize, handled with ad-hoc booleans (isDragging, maybePanning, wasShiftDown), collapses into spaghetti the moment pinch-zoom joins. Gestures are a state machine or they're nothing.

The transform โ€” world space vs screen space

The core data model decision: every shape lives in world coordinates โ€” unbounded floats, no knowledge of screens. The viewport is one tiny object:

type Viewport = { scrollX: number; scrollY: number; zoom: number };

function worldToScreen(vp: Viewport, x: number, y: number) {
  return { x: (x - vp.scrollX) * vp.zoom, y: (y - vp.scrollY) * vp.zoom };
}

function screenToWorld(vp: Viewport, x: number, y: number) {
  return { x: x / vp.zoom + vp.scrollX, y: y / vp.zoom + vp.scrollY };
}

Every renderer, every hit-test, every snap-to-grid calculation goes through this pair (or its matrix equivalent). One source of truth for coordinate conversion is what keeps zoom feeling solid.

Zoom-to-cursor is the classic trap. The world point under the cursor must stay under the cursor while zoom changes โ€” users read any drift as the board being "broken." Solve for the new scroll:

function zoomAround(vp: Viewport, screenX: number, screenY: number, nextZoom: number): Viewport {
  const clamped = Math.min(20, Math.max(0.05, nextZoom));
  const anchor = screenToWorld(vp, screenX, screenY);
  // The anchor world point must project back to the same screen pixel:
  return {
    zoom: clamped,
    scrollX: anchor.x - screenX / clamped,
    scrollY: anchor.y - screenY / clamped,
  };
}

Skip the anchor math and zooming drags the canvas sideways โ€” the single most common whiteboard bug in the wild.

Wheel handling has an intent split: trackpads fire pinch gestures as wheel with ctrlKey set. Treat ctrlKey wheel as zoom (zoomAround at the cursor) and plain wheel as pan. Clamp zoom to roughly [0.05, 20] โ€” beyond that, float precision degrades hit-testing and strokes visibly alias.

Rendering โ€” layered canvases, dirty flags, honest calibration

First, the senior move is calibration: DOM is genuinely fine under a few hundred shapes โ€” don't reach for canvas at 40 sticky notes. Past roughly 500โ€“1,000 shapes, move to canvas. Past ~100k visible, dense strokes, you're in WebGL/WebGPU territory (Figma). Volunteering where you wouldn't optimize is a signal.

The architecture: two stacked canvases.

  • Scene layer โ€” the content. Redrawn only when a shape or the viewport changes (a dirty flag, not a timer).
  • Interaction layer โ€” selections, resize handles, marquee, remote cursors. Redrawn every frame during gestures, idle otherwise.

Splitting them means dragging one sticky note doesn't repaint 50k shapes โ€” it repaints the overlay and, cheaply, one changed region of the scene.

Canvas sizing must be DPR-aware, or every Retina user gets blurred strokes:

function setupCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number) {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(cssW * dpr);    // backing store in device px
  canvas.height = Math.round(cssH * dpr);
  canvas.style.width = `${cssW}px`;
  canvas.style.height = `${cssH}px`;
  const ctx = canvas.getContext('2d')!;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // draw in CSS px from here on
  return ctx;
}

The frame loop drains dirty flags โ€” nothing draws inside a pointer event:

function frame() {
  if (sceneDirty) { drawScene(); sceneDirty = false; }
  if (overlayDirty) { drawOverlay(); overlayDirty = false; }
  requestAnimationFrame(frame);
}

pointermove fires faster than frames on high-Hz mice. Drawing per event is how you get 120Hz input with 40fps output โ€” coalesce everything into the rAF pass.

Culling โ€” the spatial index

Frame cost must be O(visible), never O(total). Viewport culling gives you that: compute the world-space viewport bounds, query only the shapes intersecting them, sort by z-order, draw.

A linear scan is honest up to ~10k shapes. But selection, marquee, and hit-testing ask the same spatial question constantly, so build the index once and share it. A uniform grid is enough for whiteboards โ€” shapes are roughly evenly distributed, and it's 30 lines:

class SpatialIndex {
  private cells = new Map<string, Set<string>>();
  private bounds = new Map<string, Bounds>();

  constructor(private cellSize = 500) {}

  insert(id: string, b: Bounds) {
    this.bounds.set(id, b);
    const x0 = Math.floor(b.x0 / this.cellSize), x1 = Math.floor(b.x1 / this.cellSize);
    const y0 = Math.floor(b.y0 / this.cellSize), y1 = Math.floor(b.y1 / this.cellSize);
    for (let cx = x0; cx <= x1; cx++)
      for (let cy = y0; cy <= y1; cy++) {
        const key = `${cx}:${cy}`;
        if (!this.cells.has(key)) this.cells.set(key, new Set());
        this.cells.get(key)!.add(id);
      }
  }

  move(id: string, b: Bounds) {
    this.remove(id);
    this.insert(id, b);
  }

  remove(id: string) {
    const b = this.bounds.get(id);
    if (!b) return;
    // clear from all cells it was registered in
    for (const ids of this.cells.values()) ids.delete(id);
    this.bounds.delete(id);
  }

  query(area: Bounds): string[] {
    const seen = new Set<string>();
    const x0 = Math.floor(area.x0 / this.cellSize), x1 = Math.floor(area.x1 / this.cellSize);
    const y0 = Math.floor(area.y0 / this.cellSize), y1 = Math.floor(area.y1 / this.cellSize);
    for (let cx = x0; cx <= x1; cx++)
      for (let cy = y0; cy <= y1; cy++) {
        const ids = this.cells.get(`${cx}:${cy}`);
        if (!ids) continue;
        for (const id of ids) {
          if (!seen.has(id) && intersects(this.bounds.get(id)!, area)) seen.add(id);
        }
      }
    return [...seen];
  }
}

One caveat to volunteer: remove scanning all cells is O(cells) โ€” for very large boards, store each id's registered cell keys and delete precisely. Mention quadtree/R-tree as the follow-up for heavy spatial clustering; the uniform grid plus a "rebuild on idle if mutated count exceeds threshold" policy is the honest 90% answer.

Z-order is array order โ€” select-to-front (Excalidraw behavior) keeps the newest interaction on top without a z-index system.

Selection โ€” screen-space furniture in a world-space scene

Empty-canvas drag starts a marquee: a world-space rectangle, queried against the index, drawn on the interaction layer. Shapes fully inside are selected (partial-intersection is a product decision โ€” say you'd follow Figma and use full containment).

Resize handles have the classic zoom trap: their size is screen-space furniture and must stay constant on screen โ€” divide by zoom:

const HANDLE_PX = 8; // constant CSS pixels

function drawHandle(ctx: CanvasRenderingContext2D, vp: Viewport, wx: number, wy: number) {
  const s = worldToScreen(vp, wx, wy);
  const size = HANDLE_PX / vp.zoom; // shrink in world units as you zoom IN
  ctx.fillRect(s.x - size / 2, s.y - size / 2, size, size);
}

Forget the division and handles become invisible dots at zoom 10 and giant squares at zoom 0.2. The same rule applies to cursor name labels and snap-alignment guide thickness.

Pointer input is an explicit state machine โ€” idle | drawing | panning | marquee | dragging | resizing โ€” with setPointerCapture so drags survive leaving the canvas element. Two-finger pinch: track both pointers, compute their center and distance ratio, and call zoomAround(center, zoom * ratio) per frame. One gesture, one entry point into the same transform code.

Collaboration โ€” per-shape LWW, presence on its own layer

Here's a decision worth making out loud: whiteboards don't need OT or CRDTs for shapes. Unlike text, a sticky note is an atomic object โ€” two people dragging it in opposite directions sensibly resolves to whoever released last. Per-shape last-writer-wins matches user expectations. Text inside shapes is where CRDTs earn their keep โ€” for that, see the collaborative editor deep-dive; the sync internals live there, not here.

The sync model: shapes live in a server-sequenced map keyed by id; ops are { type: 'upsert' | 'delete', shape, rev }; clients apply idempotently and drop stale revs. On reconnect, fetch deltas since your last rev โ€” the same discipline as the seat-booking availability stream, minus the money.

Presence is the differentiator, and it has hard performance rules:

  • Cursors render on the interaction layer only โ€” presence never dirties the scene layer.
  • Send at most one batched message per frame: pointer, selection ids, viewport. Twenty cursors each streaming per-event would be thousands of msgs/sec for zero visual gain.
  • Interpolate remote cursors between network updates (peers broadcast at ~10Hz, the render loop lerps toward the target every frame) โ€” smoothness is client-side, not network-side.
  • Name labels use the screen-space rule (size divided by zoom).
  • Ghost cursors die by heartbeat + TTL โ€” the seat-booking hold pattern again.
  • Include each peer's viewport: "follow mode" is literally rendering through another user's matrix.
// one batched presence frame, not 20 messages
conn.send({ type: 'presence', cursor: worldPt, selection: selectedIds, viewport: vp });

Undo/redo, persistence, and export

Undo follows the command pattern โ€” with one whiteboard-specific rule: a drag is one undo op, not 200. Snapshot the affected shapes at drag start, commit the delta at drag end; coalescing discipline (time windows, gesture boundaries) is covered in the undo/redo deep-dive.

Persistence is an op-log: append ops to IndexedDB, debounce a compacted snapshot, and on reconnect replay the outbox before pulling server deltas โ€” the same outbox pattern as the email client. The op-log also gives you free undo persistence across sessions.

Export falls out of the data-first model: compute the selection's world bounds, render just those shapes to an offscreen canvas at export resolution, toBlob() a PNG. SVG export is a serializer over the shape model โ€” which is exactly why shapes must be data, never pixels.

Accessibility โ€” the honest paragraph

An infinite canvas is hostile to screen readers by construction: the interesting content is pixels. Ship a semantic mirror โ€” a DOM shape-list panel (visually hidden or toggleable) with roles, labels ("sticky note: deploy checklist, moved by Priya"), arrow-key navigation between shapes, and live-region announcements for presence joins. And say the quiet part in the interview: industry-wide, canvas a11y is genuinely unsolved โ€” Excalidraw's approach is a best effort, not a solution. Knowing where your design fails is a senior signal.

Interview gotchas โ€” the quick-fire round

  • One transform everywhere โ€” render, hit-testing, snapping, and constraints all go through the same viewport matrix.
  • Zoom-to-cursor: solve for the scroll that keeps the anchor world point fixed under the cursor.
  • Handles, labels, and guides are screen-space furniture โ€” their size divides by zoom.
  • Dirty flags drained by one rAF loop โ€” never draw inside pointermove; input rate โ‰  frame rate.
  • Cull via a spatial index; frame cost is O(visible), never O(total). Know when you wouldn't build one.
  • DPR-aware backing stores, or Retina users get blur.
  • Per-shape LWW is fine for whiteboards โ€” reach for OT/CRDT only when text merges enter the picture.
  • Presence rides its own layer at batched frame cadence; interpolate between ~10Hz network updates; kill ghosts with heartbeat + TTL.
  • A drag is one undo op โ€” snapshot at gesture start, commit at gesture end.
  • The a11y answer is a semantic mirror plus honesty about the limits.

Putting it together

A production whiteboard design:

  • Data model: shapes as plain data in world coordinates, z-order as array order, viewport as { scrollX, scrollY, zoom }
  • Transform: one conversion pair for render + hit-test; zoom-to-cursor via anchor preservation; ctrl-wheel = zoom, wheel = pan; zoom clamped
  • Rendering: layered canvases with dirty flags and a single rAF drain; DOM under ~500 shapes, canvas beyond, WebGL at Figma scale; DPR-aware sizing
  • Scale: uniform-grid spatial index serving culling, hit-testing, and marquee; O(visible) frames
  • Collaboration: server-sequenced per-shape LWW with idempotent deltas; presence as batched per-frame messages on its own layer, interpolated client-side, TTL'd
  • History & storage: command-pattern undo with drag coalescing; IndexedDB op-log with outbox replay; data-first model enabling PNG/SVG export
  • A11y: semantic shape-list mirror, keyboard navigation, honest limits

The canvas is the easy 20%. The product is the transform that makes infinity boring and the sync protocol that makes twenty cursors feel local. Get the viewport matrix and the presence cadence right, and the whiteboard designs itself.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby