cf.completefrontend
My progress
SYSTEM DESIGN · #100

Design a collaborative design tool like Figma

A browser app where designers draw frames, shapes, and text on an endless canvas, and several people edit the same file at once. The hard parts are drawing tens of thousands of layers at 60 frames per second, finding what is under the pointer in under a millisecond, and merging edits from many people without a central lock.

The question

Design the web client for a collaborative design tool like Figma. Designers draw rectangles, ellipses, vector paths, text, and images on an infinite canvas, group them into frames, and turn them into reusable components. They pan and zoom freely, select and drag things, and see a layers panel and a properties panel.

Several people can edit the same file at once and see each other's cursors and changes live. Some files have 50,000 layers across many pages. Walk me through how the document is modelled, how it is drawn, how selection and dragging work, and how edits from different people are merged.

Real-life examples: Figma, Penpot, Framer, Canva, Adobe XD (discontinued)

Requirements: scoping a design tool

"Build Figma" is really four products in one box: a drawing engine, an editor with tools and panels, a sync engine for many people, and a platform for plugins and export. An hour is not enough for all four, so the first five minutes go to questions whose answers change the design. Ask about size first, because size decides how you render and how you find things under the pointer. Then ask how live collaboration must be, and what a file can contain.

The table lists the questions in the order a senior engineer would ask them, the answer this guide assumes, and what part of the design moves if the answer is different. Write the assumed answers on the board. Every later number in the design comes from them.

QuestionAnswer we assumeWhat it changes
How big can one file get?Up to about 50,000 layers across 20 pages, with 500 images. A typical file has 2,000 to 5,000 layers.Whether we can draw everything every frame, and whether we load pages lazily.
How smooth must pan and zoom be?60 frames per second on a 2020 laptop, so about 16 ms per frame, with less than 10 ms of that spent drawing.Rendering technology (GPU, not the DOM) and how much work each frame may do.
How many people edit one file at once?Usually 1 to 5. Up to 50 editors and 200 viewers in a design review.How we merge edits and how we throttle cursor updates.
What can a layer be?Frames, groups, rectangles, ellipses, vector paths, text, images, components, and instances of components.The node types in the document tree and their properties.
Must it work offline?Keep editing through short drops (a minute or two). Full offline work for hours is a nice to have.How much we queue on the device and how we resume.
Do we support plugins?Yes, third party code that can read and change the document.We need a sandbox so a plugin can't steal data or break the page.
What do people export?PNG, JPG, SVG, and PDF of any layer, at 1x to 4x scale.Export runs off the main thread, and the renderer must be reusable for it.

Functional requirements

  • Open a file and see the current page drawn within about 3 s, even when the file is large.
  • Pan and zoom an infinite canvas from about 1% to several thousand percent, with the drawing staying sharp at every level.
  • Create shapes, text, and frames with tools; select, move, resize, rotate, and reorder them with the mouse, touch, or keyboard.
  • Group layers, nest frames, and set constraints so children react when a parent frame is resized.
  • Make a component and place instances of it; change the component and see every instance update, while keeping each instance's overrides.
  • See other editors' cursors, names, and selections live, and their edits within about 200 ms.
  • Undo and redo your own changes, without undoing other people's work.
  • Use a layers panel (the tree of everything on the page) and a properties panel (position, size, fill, stroke, text style) for precise edits.
  • Run plugins, and export any layer to PNG, SVG, or PDF.

Non-functional requirements

  • Frame budget: pan, zoom, and drag at 60 fps. A dropped frame while dragging feels like the shape is sticky.
  • Input latency: a dragged shape follows the pointer in the next frame, under about 20 ms from event to pixels. Hover highlight must also land in the same frame.
  • Memory: stay under about 1.5 GB for a 50,000 layer file, so the tab is not killed on an 8 GB laptop.
  • No lost work: every change reaches the server within a second or two, survives a reload, and is never silently overwritten.
  • Correct merging: two people editing the same file always end up seeing the same document.
  • Security: a plugin or a shared file must never run code with the user's session.
  • Accessibility: every command reachable by keyboard, and the layers and properties panels usable with a screen reader. The canvas itself has real limits here, and we will be honest about them.

Out of scope, and why

  • Prototyping and animation playback: a separate viewer with its own runtime. Mention it, then park it.
  • Code generation for developers (Dev Mode): it reads the same document, so it adds no new design problems.
  • Server storage and the file format on disk: we treat the server as a box that stores files and relays changes, and only design what the client needs from it.
  • Comments: same anchoring problem as comments in a text editor, but pinned to a canvas point or a node. One sentence is enough.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a design tool hard

Most web apps show lists of things that the browser lays out for you. A design tool is the opposite: the app owns every pixel, every hit test, and every drag, because the browser's layout engine doesn't know what a rotated vector path with a blur is. Five properties of the problem drive every later decision. Keep them in mind; each chapter answers one of them.

  1. Huge and free form. A page is an endless plane with tens of thousands of layers, at any position, rotation, and zoom. Nothing is in a neat list, so nothing comes for free from the DOM. You need your own renderer and your own way of finding what is under the pointer.
  2. Continuous interaction. People drag, resize, and zoom constantly. A drag produces a pointer event every 4 to 16 ms, and each one must update the shape, the snapping guides, the properties panel, and the screen inside one frame. A page that is merely "fast enough to load" is not enough; every frame must be fast.
  3. Deeply nested and linked. Layers live inside frames inside other frames. Components link instances across the whole file, so changing one master can change 3,000 layers. One edit can ripple far from where it was made.
  4. Many writers, no locks. Five people can drag, recolor, and delete the same things at the same moment. Everyone must end with the same file, nobody's work may vanish silently, and your own edits must never wait for the network.
  5. Untrusted code inside. Plugins written by strangers read and change the document. They must be powerful enough to be useful and boxed in enough to be safe.
A wireframe of the design editor at figma.example.com/file/f_8Kq2. A toolbar has the move tool V selected, the rectangle tool R, the text tool T, two avatars A and B, and a Share button. On the left, the layers panel lists Checkout, and inside it Header (selected), Button, and Card. In the middle, the canvas shows the Checkout frame with the selected Header and a Button, and Ben's cursor. On the right, the properties panel shows X 24, Y 16, W 320, H 56, Fill #1E6FFF, and Stroke none. Labels say the layers and properties panels are DOM, and the canvas is drawn by our GPU engine.figma.example.com/file/f_8Kq2VRTABShareLayersCheckoutHeaderButtonCardlayers (DOM)CheckoutHeaderBenButtoncanvas (GPU)DesignX 24Y 16W 320H 56Fill #1E6FFFStroke noneproperties (DOM)
Only the middle area is drawn by our own engine. The layers panel and the properties panel are ordinary DOM, which is why they carry most of the keyboard and screen reader support, and why they must not re-render on every frame.

The wireframe above names the parts of the screen we are designing: the toolbar across the top, the layers panel on the left, the canvas in the middle with other people's cursors on it, and the properties panel on the right. Only the canvas is drawn by our own renderer. The panels are normal DOM built with React, which matters later for accessibility and for how state flows between the two worlds.

Two worlds on one page

It helps to see the app as two programs sharing one document. The canvas engine owns the scene graph, draws it on the GPU, and answers "what is under this point?". The UI shell is ordinary React: menus, panels, dialogs, and inputs.

They talk through a narrow bridge: the engine publishes "selection changed" and "properties of the selected nodes are now X", and the shell sends commands like "set fill of node 12:7 to red". Keeping this boundary narrow is what lets the engine be fast (no React re-render per frame) and the shell be accessible (real buttons and inputs).

Think first

Why not draw every layer as an absolutely positioned div or an SVG element and let the browser do the work?

The scene graph: modelling the document

Everything else sits on the document model, so we design it first. The naive model is a flat array of shapes, each with absolute x, y, width, and height. It renders easily and fails the moment someone makes a frame.

Move the frame and you must find and move all 400 shapes "inside" it, and you don't even know which ones are inside, because nothing records it. Rotate a group and every child needs new coordinates. Make a component and you have no way to say "these 30 shapes are one thing, used 200 times".

Design tools use a scene graph: a tree of nodes where each node is a layer, its children are the layers inside it, and each node's position is stored relative to its parent. The root is the document, its children are pages, a page's children are top level frames and shapes, and so on down. Move a frame and you change one number; the children move with it because their positions are relative.

On the left, a scene graph: the page Flows has two children, the frame Checkout (id 1:4) and the vector Logo (id 1:9). Checkout has three children: the rectangle Header (1:5), the instance Pay (2:7), and the text Total (3:2). Positions are relative to the parent. On the right, the same tree as a flat map, one row per node with its parent and position: 1:4 in 0:1 at a0, 1:5 in 1:4 at a0, 2:7 in 1:4 at a1, 3:2 in 1:4 at a2, 1:9 in 0:1 at a1. A note says a move changes one parentIndex value.Page: FlowsCheckoutFRAME 1:4LogoVECTOR 1:9HeaderRECT 1:5PayINSTANCE 2:7TotalTEXT 3:2positions are relative to the parentthe flat map1:4 in 0:1 at a01:5 in 1:4 at a02:7 in 1:4 at a13:2 in 1:4 at a21:9 in 0:1 at a1a move changes oneparentIndex value
The tree is what people see in the layers panel. The flat map is what the client stores and syncs: each node knows its own parent and its place among siblings, in one parentIndex property.

Node types and their properties

Node typeWhat it isKey properties
Document, PageThe file and its pages. Only one page is shown at a time.name, background color, child order
FrameA box with its own size that can clip its children, hold auto layout, and be exported. The workhorse container.size, fills, clipsContent, layout mode, padding, gap
GroupA plain bundle of layers. Its bounds are computed from its children; it has no size of its own.none beyond transform, opacity, and blend
Rectangle, Ellipse, Polygon, StarBasic shapes described by a few numbers.size, corner radius, point count
VectorA free path made of points and curves, edited with the pen tool.vector network (points, segments, curve handles), winding rule
TextA block of styled characters, laid out by our own text engine.characters, style runs (font, size, weight, color), auto resize mode
ComponentA master definition that can be reused.the subtree it contains, its published properties
InstanceA live copy of a component.componentId, a list of overrides

Every visible node also shares a common set of properties: name, visible, locked, opacity, blendMode, fills, strokes, effects (shadows and blurs), a transform, a size, and constraints. Fills are a list, not one color, because a layer can stack a solid color, a gradient, and an image.

Transforms: local and world

Each node stores a relative transform: a 2 by 3 affine matrix that maps the node's own coordinates into its parent's. An affine matrix holds translation, rotation, scale, and skew in six numbers [a, b, c, d, tx, ty].

To draw a node or test a click against it, you need its world transform: the product of every matrix from the page down to the node. Multiplying 6 to 10 matrices per node per frame for 50,000 nodes is wasteful, so the engine caches each node's world transform and world bounding box, and marks them dirty when the node or any ancestor changes.

ts
type Mat = [a: number, b: number, c: number, d: number, tx: number, ty: number];

function multiply(p: Mat, c: Mat): Mat {
  return [
    p[0] * c[0] + p[2] * c[1], p[1] * c[0] + p[3] * c[1],
    p[0] * c[2] + p[2] * c[3], p[1] * c[2] + p[3] * c[3],
    p[0] * c[4] + p[2] * c[5] + p[4], p[1] * c[4] + p[3] * c[5] + p[5],
  ];
}

function worldTransform(node: SceneNode): Mat {
  if (!node.worldDirty) return node.worldCache;
  const parent = node.parent;
  node.worldCache = parent ? multiply(worldTransform(parent), node.transform) : node.transform;
  node.worldDirty = false;
  return node.worldCache;
}

// Moving a frame marks its whole subtree dirty; nothing is recomputed until it is drawn or hit tested.
function markDirty(node: SceneNode) {
  node.worldDirty = true;
  for (const child of node.children) markDirty(child);
}
World transforms are cached and recomputed lazily, only for nodes under a change.

A worked example: a frame sits at (100, 50) on the page. Inside it, a rectangle sits at (20, 10) and is rotated 90 degrees. The rectangle's world transform is the frame's translation times the rectangle's own translate and rotate, so its top left corner lands at (120, 60) on the page and its width now points straight down.

Drag the frame 30 units right and only the frame's tx changes from 100 to 130. The rectangle's stored numbers don't change at all, which also means a collaborator who is recoloring the rectangle at the same moment never conflicts with you.

Constraints: what children do when a frame resizes

Relative positions solve moving, not resizing. When someone makes a 375 wide phone frame 414 wide, a button pinned to the right edge should stay pinned. Each child has constraints per axis: MIN (keep the distance to the left or top edge), MAX (keep the distance to the right or bottom), STRETCH (keep both, so the child grows), CENTER, and SCALE (keep the proportion).

On a resize, the engine applies them to each direct child and writes the new transforms and sizes. Those writes are ordinary property changes, so they sync to other people like any other edit. Auto layout frames go one step further and position children like CSS flexbox: direction, gap, padding, and alignment.

Storing the tree: nested or flat?

Decision How should the client store the scene graph in memory?

Nested objects with children arrays

Each node holds an array of its child nodes, like a JSON tree.

The order of children is the order in the array.

Pros

  • Natural to read and to walk for drawing.
  • Matches how people think about layers.

Cons

  • Finding node 12:7 needs a tree walk or a separate index.
  • Moving a node to another parent edits two arrays: a two step change that can half fail when two people do it at once.
  • Reordering means editing array positions, which conflict when two people reorder the same parent.

Flat map of nodes, each pointing at its parent

One map from node ID to node. Each node stores its parent's ID and a position key that sorts it among its siblings.

Children lists are rebuilt from the map and cached.

Pros

  • Look up any node in O(1).
  • Reparenting is a change to one property on one node, so it merges like any other property.
  • Order is a sortable key on the child, so two people inserting in the same parent never fight over array slots.

Cons

  • Children must be re-derived and sorted when they change.
  • A bad update could create a cycle (A inside B inside A), which must be checked.

Flat map plus cached children arrays

The flat map is the source of truth for sync.

The engine keeps a derived children array per parent, rebuilt only for parents whose children changed.

Pros

  • All the merge benefits of the flat map.
  • Drawing and the layers panel walk ready made arrays.

Cons

  • Two structures to keep consistent, so all writes must go through one function.

What we pick: Use a flat map as the source of truth with derived children arrays (option 3). The sync chapter will show why: when every fact about a node, including its parent and its place among siblings, is a separate property on that node, merging two people's edits becomes a per-property decision instead of a tree surgery problem. Figma's multiplayer post describes the same shape: the document is a map from object ID to that object's properties, the link to the parent lives on the child, and the parent link and the position are stored together as one property so a move can never leave a node with a new parent and an old position.

ts
type NodeId = string; // "sessionId:counter", for example "42:1093"

type SceneNode = {
  id: NodeId;
  type: "FRAME" | "GROUP" | "RECTANGLE" | "ELLIPSE" | "VECTOR" | "TEXT" | "COMPONENT" | "INSTANCE";
  // one property, so a move updates parent and place together
  parentIndex: { parentId: NodeId; position: string }; // position: fractional index, "a0", "a0V"...
  name: string;
  visible: boolean;
  locked: boolean;
  transform: Mat;              // relative to the parent
  size: { w: number; h: number };
  fills: Paint[];
  strokes: Paint[];
  effects: Effect[];
  constraints: { h: Constraint; v: Constraint };
  // type specific: characters and style runs for TEXT, componentId and overrides for INSTANCE...
};

type Document = Map<NodeId, SceneNode>;
The client model: every fact about a node is a property on that node.

Check your understanding · Exercise 2

A designer drags a frame that contains 400 layers 30 units to the right. With the model above, which statements are true? Select all that apply.

Rendering: drawing 50,000 layers at 60 fps

We have a tree of nodes. Now we must turn it into pixels, again and again, while the user pans and zooms. The naive renderer uses the Canvas 2D API and, on every frame, clears the canvas and draws every node on the page with fillRect, fill(path), and fillText.

For a 300 layer mockup that works. For the 50,000 layer design system file it fails: even at a cheap 20 microseconds per simple shape, 50,000 shapes cost a full second per frame, and shapes with shadows, blurs, and image fills cost much more. Panning becomes a slideshow.

Two separate ideas fix this, and it pays to keep them apart in the interview. One is which drawing API to use. The other is how little work to do per frame: draw only what is visible, only what changed, and reuse what was drawn last time. The second idea matters more than the first, but both are needed at this size.

Choosing a drawing API

Decision Which browser API should draw the canvas?

DOM or SVG elements

One element per layer, positioned with CSS transforms.

The browser does layout, painting, hit testing, and accessibility.

Pros

  • Free hit testing with events on each element.
  • Text is real text, selectable and readable by screen readers.

Cons

  • Thousands of elements cost a lot of memory and style work.
  • Zoom touches every element, causing layout and paint each frame.
  • No control over effects; output won't match export exactly.

Canvas 2D

One canvas element. JavaScript issues immediate mode drawing calls (the canvas keeps no list of shapes) each frame.

Paths, gradients, images, and text through a simple API.

Pros

  • Simple to write and debug; text drawing built in.
  • Good enough for whiteboards with a few thousand shapes (simpler whiteboards such as Excalidraw draw this way).

Cons

  • Immediate mode: all geometry is sent again every frame, and every call crosses from JavaScript into the browser.
  • Hard to batch thousands of shapes into a few GPU calls.
  • Effects are limited to the blend modes and filters the API offers, and anti-aliasing and text can differ between browsers, so the screen may not match the export.

WebGL 2

Talk to the GPU directly: upload shape geometry and textures, and draw them with your own shaders.

Your code decides anti-aliasing, blending, masking, and effects.

Pros

  • Thousands of shapes in a handful of draw calls.
  • Any effect you can write a shader for.
  • Available in every current browser.

Cons

  • You build everything: path filling, strokes, text, images, clipping.
  • The GPU context can be lost at any time and must be rebuilt.
  • Global state machine API that is easy to misuse.

WebGPU

The modern GPU API: explicit pipelines and bind groups, plus compute shaders.

Shipped in Chrome in 2023, and in Safari and Firefox (on Windows) in 2025.

Pros

  • Less CPU overhead per draw, explicit state, and compute shaders that can move work like tessellation onto the GPU.

Cons

  • Still missing on some devices and drivers, so you need a fallback anyway.
  • Newer, with fewer tools and examples.

What we pick: Draw with the GPU: a renderer that targets WebGL 2 everywhere and WebGPU where it works, behind one interface. That is the path Figma took: its 2015 post describes a tile based WebGL engine written in C++ and compiled for the browser with Emscripten (a C++ to JavaScript and WebAssembly compiler), and its September 2025 post describes moving that renderer to WebGPU with a dynamic fallback to WebGL when a device misbehaves. In the interview, say Canvas 2D would be fine for a smaller whiteboard, and explain the file size and effects that push you to the GPU.

The camera: pan and zoom as one matrix

The canvas element never moves. Instead, a camera says which part of the infinite page is visible: an x and y in page coordinates at the top left of the screen, and a zoom.

Every node's world transform is multiplied by the camera matrix to get screen pixels, then by devicePixelRatio so the drawing is sharp on a retina screen (the canvas backing store is 2 or 3 times the CSS size). Panning changes x and y; zooming changes zoom and moves x and y so the point under the cursor stays still, which is what makes a pinch zoom feel anchored.

ts
type Camera = { x: number; y: number; zoom: number }; // x, y: page point at screen top left

const toPage = (cam: Camera, sx: number, sy: number) => ({
  x: cam.x + sx / cam.zoom,
  y: cam.y + sy / cam.zoom,
});

function zoomAt(cam: Camera, sx: number, sy: number, factor: number): Camera {
  const zoom = Math.min(256, Math.max(0.01, cam.zoom * factor));
  const anchor = toPage(cam, sx, sy);
  return { zoom, x: anchor.x - sx / zoom, y: anchor.y - sy / zoom };
}

canvas.addEventListener("wheel", (e) => {
  e.preventDefault(); // needs { passive: false }
  if (e.ctrlKey) camera = zoomAt(camera, e.offsetX, e.offsetY, Math.exp(-e.deltaY * 0.01));
  else camera = { ...camera, x: camera.x + e.deltaX / camera.zoom, y: camera.y + e.deltaY / camera.zoom };
  requestFrame();
}, { passive: false });
Zoom around the cursor: the page point under the pointer is the same before and after.

On trackpads, Chromium and Firefox report a pinch as a wheel event with ctrlKey set to true, which is why the code checks it. Safari on macOS also fires its own non-standard gesturestart, gesturechange, and gestureend events with a scale value, so canvas apps usually handle those too. Note the { passive: false }: without it the browser may scroll or zoom the whole page before our handler can call preventDefault().

Doing less work per frame

The frame pipeline in six steps. Step 1, a change marks something dirty: the camera, a node, or an image. Step 2, cull with the R-tree, for example 300 of 50,000 nodes. Step 3, pick the level of detail for the zoom, with mipmaps for images. Step 4, the tile cache reuses tiles that are still valid. Step 5, draw only the dirty tiles, with batched draw calls. Step 6, composite the tiles to the screen. When nothing is dirty, the editor draws no frames at all.1 a changecamera, node, image2 cullR-tree: 300 of 50,0003 pick detailzoom level, mipmaps4 tile cachereuse what is valid5 draw tilesdirty ones, batched6 compositetiles to the screennothing dirty: no frame is drawn at all
Each step throws work away before the expensive part. By the time anything is drawn, only the visible, changed tiles are left.
  1. Render on demand. Run a requestAnimationFrame loop only while something is dirty: the camera moved, a node changed, or an image finished loading. An idle editor draws nothing and uses no battery.
  2. Cull. Ask the spatial index (next chapter) which nodes' world bounds overlap the visible rectangle. At 100% zoom on a big file, that is usually a few hundred of the 50,000 nodes. Skip the rest without touching them.
  3. Skip detail that can't be seen. At 5% zoom a 12 px label is less than a pixel tall. Draw it as a grey bar, or not at all. Draw images from a smaller, pre-scaled version (a mipmap) instead of the 4,000 px original.
  4. Cache in tiles. Rasterize the page into square tiles (for example 512 by 512 device pixels) at the current zoom and keep them as GPU textures. A pan then mostly copies existing tiles to new screen positions and renders only the strip of tiles that just came into view.
  5. Redraw dirty rectangles only. When one shape changes, mark the union of its old and new bounds as dirty, and re-render only the tiles that rectangle touches. Moving a 40 px icon redraws one or two tiles, not the screen.
  6. Batch. Group shapes that use the same shader and texture into one draw call. The GPU is fast at drawing; what is slow is the number of separate calls and state changes.
A grid of cached tiles, five columns by three rows, each 512 by 512 device pixels. The user pans right by one tile. The dashed viewport now covers columns 2 to 5. The nine tiles in columns 2 to 4 are reused as they are. The three tiles in column 5 are drawn new. Column 1 is old and can be evicted later. Notes say: pan right means copy 9 tiles and draw 3 new ones; zoom means stretch the tiles, then redraw them sharp.oldreusereusereusedrawoldreusereusereusedrawoldreusereusereusedrawviewport after the panone tile = 512 x 512 device pixelspan right:copy 9 tiles,draw 3 newzoom: stretchthe tiles, thenredraw sharp
A pan moves pixels that already exist. Only the strip that just came into view needs real drawing, which is why tiles make panning cheap even on a huge page.

Zoom is the hard case for tiles, because every cached tile is now the wrong resolution. The usual trick: during the gesture, stretch the existing tiles (they look slightly soft for a moment), and when the zoom settles for about 100 to 200 ms, re-render the visible tiles at the new resolution, nearest to the center first. Users accept a brief blur; they do not accept a stutter. Keep the tile cache inside a budget (say 256 MB of textures) and evict the least recently used tiles first.

Text on a GPU canvas

WebGL has no fillText. A design tool also needs the same line breaks on every machine and in every export, which browser text layout does not promise. So the engine owns text: it loads font files, shapes the characters into positioned glyphs (a shaping library such as HarfBuzz, compiled to WebAssembly, handles ligatures, kerning, and scripts like Arabic and Devanagari), breaks lines itself, and draws glyphs either as vector outlines or from a cached glyph atlas texture.

A cheaper first version draws each text node with Canvas 2D fillText into an offscreen texture and uploads it, accepting small differences between machines. Editing text in place is a different story: the editing internals (caret, selection, and composition through an IME, the input method editor used to type languages such as Japanese and Chinese) are covered in the rich text editor guide, and a common shortcut is to place a transparent DOM text field over the node while it is being edited.

When the GPU goes away

The browser can take the GPU context away at any time: a driver crash, too many tabs using WebGL, or the laptop switching graphics cards. The canvas fires webglcontextlost, and every texture and buffer is gone.

Because the scene graph is the source of truth and tiles are only a cache, recovery is simple: call preventDefault() on the event so the browser will try to restore the context, then on webglcontextrestored rebuild shaders and buffers and redraw. If the renderer kept the only copy of anything on the GPU, that would be lost work.

Check your understanding · Exercise 3

A user pans quickly across a 50,000 layer page at 100% zoom. Which techniques keep each frame inside the 8 to 10 ms drawing budget? Select all that apply.

Hit testing and selection: what is under the pointer?

With a DOM, the browser tells you which element was clicked. On our canvas, a click is just an (x, y) in screen pixels, and we must answer "which layer is that?" ourselves.

We ask it far more often than on clicks: every pointermove updates the hover outline, so the question runs 60 to 120 times a second while the mouse moves. The answer must be correct (a thin diagonal line, a ring with a hole, a rotated star) and it must take well under a millisecond, because it shares the frame with drawing.

The naive answer loops over every node from the topmost down, converts the point into the node's local space, and runs an exact geometry test. On 50,000 nodes with real path tests, that costs several milliseconds per move, and more for vector paths with hundreds of curve segments. The fix is the same idea the renderer uses: don't look at things that can't possibly match. Split the work into two phases.

  1. Broad phase: a spatial index returns the few nodes whose world bounding box contains the point. Cheap, approximate, and usually 1 to 10 candidates.
  2. Narrow phase: for each candidate, from top of the z-order down, run the exact test. Transform the point by the inverse of the node's world matrix, then test the real shape: corner radius rectangle, ellipse equation, or a path winding test for vectors, with the stroke width added. The first hit wins.

Choosing a spatial index

Decision How do we find the candidates near a point or inside a marquee quickly?

Linear scan of bounding boxes

Keep a flat array of world bounds and check each one.

Pros

  • No structure to maintain; always correct.
  • Fine for a few thousand nodes.

Cons

  • Cost grows with the whole page, not with what is near the pointer.
  • 50,000 checks per pointer move wastes most of a frame.

Uniform grid

Split the page into fixed cells, say 512 by 512 units. Each cell lists the nodes whose bounds touch it.

A point query reads one cell.

Pros

  • Very simple, and updates are cheap: remove from old cells, add to new ones.
  • Great when shapes are similar in size and evenly spread.

Cons

  • A huge frame or background touches hundreds of cells.
  • Dense areas pile hundreds of nodes into one cell, while empty areas waste memory. Design files are exactly this uneven.

R-tree (a tree of bounding boxes)

Nodes are grouped into boxes, boxes into bigger boxes, about 9 to 16 children each.

A query only descends into boxes that overlap the point or rectangle.

Pros

  • Adapts to uneven density and to shapes of any size.
  • Point and rectangle queries in roughly O(log n) plus the results.
  • Fast bulk loading when a page opens.

Cons

  • Updates are more complex; many moves can make boxes overlap and slow queries, so rebuild occasionally.
  • More code than a grid (a well known library helps).

GPU color picking

Render every node into a hidden buffer, each with a unique color that encodes its ID, then read the pixel under the pointer.

Pros

  • Exact for any shape the renderer can draw, with no geometry code.

Cons

  • Reading pixels back from the GPU stalls the pipeline and adds a frame of latency.
  • Anti-aliasing blends colors at edges, and the buffer must be redrawn whenever anything moves.
  • No help for marquee selection or snapping queries.

What we pick: Use an R-tree of world bounding boxes per page, holding the direct children of the page and of every frame, then the exact geometry test in the narrow phase. It handles the uneven, nested layouts of real design files, and the same index also answers the renderer's culling query, marquee selection, and snapping's "what is near me?" query. A quadtree is an equally good answer if you explain it well; the point is to make queries depend on what is nearby, not on file size.

MethodLayout

Linear scan: checked 120 of 120 shapes for the pointer at (250, 150). Hit shape 112. The busiest grid cell holds 18 shapes.

A page of 120 small shapes, spread across the page, with the pointer at (250, 150). Linear scan: checked 120 of 120 shapes for the pointer at (250, 150). Hit shape 112. The busiest grid cell holds 18 shapes.checked: 120of 120 shapeshit: shape 112busiest cell: 18cost grows withthe whole page
A linear scan checks every shape wherever you point. The grid checks only the shapes in the pointer's cell, until shapes crowd into one cell and that count climbs again.

Play with the lab above. With a linear scan, the number of checks equals the number of shapes no matter where you point. With a grid, it drops to the shapes in one cell. Now crowd the shapes into one corner: the busy cell fills up and its count rises, which is exactly why an adaptive structure like an R-tree is the safer choice for real files.

Picking rules: which layer does a click mean?

Finding every node under the point is not the end. Design tools pick by rules that feel natural to designers, and you should mention them because they change what the hit test returns:

  • Topmost wins: the narrow phase walks candidates in reverse paint order, so what you see on top is what you get.
  • Context matters: a single click on a shape inside a group selects the group. A double click enters the group and selects the shape. A modifier (Cmd on macOS, Ctrl on Windows) selects the deepest layer directly.
  • Skip what can't be picked: hidden and locked layers are ignored. A top level frame works like an artboard: clicking its name label selects it, while a drag on its empty area usually starts a marquee instead of moving the whole frame.
  • Tolerance in screen pixels: a 1 unit line at 25% zoom is a quarter of a pixel wide. Add a tolerance of about 4 screen pixels, converted to page units by dividing by zoom, so thin lines stay clickable at any zoom.

Marquee selection and handles

Dragging on empty canvas draws a marquee rectangle. It becomes a rectangle query on the same index, then a narrow phase that checks whether each candidate's actual shape intersects the box. Tools differ on whether touching is enough or full containment is required; pick one, and make it consistent. Selection with Shift adds or removes nodes.

Once something is selected, the engine draws a selection box with eight resize handles and invisible rotation zones just outside each corner. Handles are tested before any layer, and in screen space: an 8 by 8 pixel handle stays 8 pixels at every zoom. That ordering matters. If layers were tested first, a small shape under a handle would steal the click, and resizing tiny icons would be impossible.

Check your understanding · Exercise 4

Hover highlighting feels laggy on a 50,000 layer page. Which changes would help? Select all that apply.

Editing interactions: drag, snap, resize, nudge

Selection tells us what the user is pointing at. Now they press and move. A drag in a design tool looks simple and hides most of the editor's subtle bugs: drift, jitter, lost pointers, a hundred undo steps for one move, and collaborators seeing a shape teleport. The cure is to treat each gesture as a small state machine with a clear start, a stream of previews, and one commit or cancel at the end.

A state machine for pointer gestures. From idle, pointerdown leads to pressing. From pressing, pointerup without movement is a click and goes back to idle. After more than 3 pixels of travel, pressing becomes dragging, resizing, rotating, or marquee, depending on what was hit. From any of those, pointerup commits one undo step and returns to idle, and Escape or pointercancel restores the start state and returns to idle.idlepressingdraggingresizingrotatingmarqueedownup: clickpast 3 pxfrom any: up commit, Esc cancelevery gesture ends in one commitor one cancel, never half
Nothing moves in pressing. The 3 pixel threshold separates a click from a drag, and every active state ends in exactly one commit or one cancel.

Walk through the machine. On pointerdown the tool hit tests handles first, then layers, then empty canvas, and enters pressing with a note of what was hit. Nothing moves yet: a click and a drag start the same way.

Only when the pointer travels more than about 3 screen pixels does the machine enter dragging, resizing, rotating, or marquee. That threshold is what stops a slightly shaky click from nudging a shape by one pixel. pointerup commits; Escape or pointercancel puts every node back where it started.

Pointer events done right

  • One event model. Pointer events cover mouse, touch, and pen with the same handlers. Set touch-action: none on the canvas so the browser doesn't turn a finger drag into page scrolling.
  • Pointer capture. Call canvas.setPointerCapture(e.pointerId) on pointerdown. All later moves for that pointer come to the canvas even when the pointer leaves it or crosses a panel, and pointerup is never lost outside the window.
  • Absolute, not incremental. At drag start, save each selected node's start transform and the start point in page coordinates. On each move compute delta = (current - start) / zoom and set transform = start + delta. Adding small deltas move by move accumulates rounding error and drifts away from the cursor.
  • One update per frame. Pointer moves can arrive faster than the display refreshes. Store the latest position and apply it in the next requestAnimationFrame. For the pen tool, read e.getCoalescedEvents() to get every intermediate point for a smooth curve.
A sequence diagram of a drag with six columns: User, Tool, Store, Render, Sync, Server. The user presses; the tool begins an undo step. The user moves the pointer; the tool sets the transform in the store; the store asks the renderer to redraw next frame; the store hands the latest value to sync every 50 ms; sync sends changes to the server. The user releases, which commits the step. The server replies with an ack or a reject. On a reject, sync tells the store to roll back to the server's value.UserToolStoreRenderSyncServerpointerdownbegin steppointermovetransformredrawevery 50 mschangesup: commitack/rejectroll back
The shape follows the pointer from the local store, so the network never sits between the hand and the pixels. The server's answer comes later, and only a rejection changes anything on screen.

The sequence shows a drag end to end. The local document changes on every frame, so the shape follows the pointer with no network wait. Collaborators also see the shape slide rather than jump, because the client sends the in-progress transform about every 50 ms.

Only the final value at pointerup becomes one undo step. If the server rejects the change (for example the user's edit access was just removed), the client rolls the node back to the server's value and shows a message.

Snapping and smart guides

Designers expect a dragged box to click into line with its neighbours. Snapping means: if an edge or the center of the moving selection is within a few pixels of an edge or center of another layer, shift the selection so they line up exactly, and draw a red guide line with the distance. The naive version compares the moving box with every node on the page on every move. The fast version prepares once, at drag start:

  1. Query the spatial index for nodes visible in the viewport, excluding the ones being dragged. Usually 50 to 300 nodes.
  2. Collect their snap lines: left, center, and right x values, and top, middle, and bottom y values, plus the parent frame's edges and the layout grid. Sort each list.
  3. On each move, take the dragged box's three x values and three y values, and binary search each in the sorted lists for the nearest line within the threshold: about 5 screen pixels, so 5 / zoom page units.
  4. Apply the smallest correction per axis, then draw guides for every line that now matches exactly. Equal spacing hints ("these three cards are 24 apart") come from comparing gaps between neighbours in the same sorted lists.

That is six binary searches over a few hundred numbers per frame: microseconds. Holding a modifier key to switch snapping off for one drag is a common and welcome escape hatch.

Transforming many layers at once

With several layers selected, the selection box is the union of their world bounds. Moving is easy: the same delta applies to each node's start transform. Resizing is not: dragging the right handle scales the whole selection from its left edge, so each node's position and size are scaled relative to that anchor. Holding Shift keeps the aspect ratio and holding Alt (Option) scales around the center, the usual conventions.

A rotated child inside the selection can't simply get a new width and height without skewing, so the engine either scales the child's own frame or bakes the change into the node's transform. Rotation of a multi selection spins every node around the box center, changing both position and angle. Each of these writes ordinary properties (transform, size), which keeps sync and undo the same for every tool.

Keyboard nudges and shortcuts

Arrow keys move the selection by 1 unit, and Shift with an arrow by 10. A held arrow key repeats keydown about 10 to 30 times a second, depending on the operating system's setting, so treat a run of nudges as one gesture: apply each locally right away, coalesce them into one undo step that closes after about 500 ms without a key press, and send the latest transform to the server at the same steady rate as a drag.

Shortcuts for tools (V for move, R for rectangle, T for text) must not fire while focus is in a panel text field; check e.target before acting, and respect e.isComposing so IME users typing in a field never trigger tools.

Check your understanding · Exercise 5

A user drags three selected shapes for 2 seconds and releases. Which behaviours are correct? Select all that apply.

Multiplayer: merging edits from many people

Five designers have the file open. Ana moves the hero card, Ben changes its fill to blue, and Chen renames it, all within the same second. The naive sync sends the whole document on save and keeps whatever arrived last.

Ana's save lands last and quietly puts back the old color and the old name: Ben's and Chen's work is gone, and nobody knows. A file lock ("Ana is editing, everyone else is read only") avoids the loss but kills the product: live collaboration is the reason people pick a browser design tool.

We need a way to merge edits made at the same moment. The general background is in the collaboration basics, conflict resolution, operational transformation, and CRDTs lessons. Here we pick the approach that fits a design document, which is a different shape from a text document.

Decision How should concurrent edits from many clients be merged?

Lock the file or the layer

Only one person may edit the file, or each layer, at a time. Others wait or see it read only.

Pros

  • No merge logic at all.

Cons

  • Kills live collaboration.
  • Stale locks when a laptop sleeps mid edit.
  • Edits can't be made offline.

Operational transformation (OT)

Each edit is an operation. The server transforms concurrent operations against each other so they still make sense, as Google Docs does for text.

Pros

  • Proven for long text, where positions shift as people type.

Cons

  • A transform function is needed for every pair of operation types; a design tool has dozens (move, resize, reparent, reorder, recolor...), and the pairs grow fast.
  • Notoriously hard to prove correct.

Full CRDT, peer to peer

Use a CRDT library such as Yjs or Automerge. Every replica can merge with any other without a central server.

Pros

  • Works offline and peer to peer by construction.
  • Libraries exist.

Cons

  • Keeps metadata such as tombstones and version vectors, which costs memory on 50,000 node files.
  • We have a server anyway, so we pay for decentralization we don't use.
  • Permission checks still need a server to enforce them.

Server ordered, last writer wins per property

Every change is (node ID, property, value). The server applies changes in the order they arrive and keeps the latest value for each property of each node.

Clients apply their own changes immediately and fix up when the server's order disagrees.

Pros

  • Different properties, or different nodes, never conflict, which covers almost all real collisions.
  • Simple to reason about, test, and extend: a new feature is usually just a new property.
  • No per-character history to store.

Cons

  • A property value is atomic: two people editing the same text layer at once don't get a merged string; one value wins.
  • Needs a server to define the order.

What we pick: Server ordered, per-property last writer wins. This is what Figma describes in its multiplayer post: inspired by CRDTs (a last-writer-wins register per property, with the server's arrival order standing in for timestamps), but centralized, so it avoids CRDT overhead and is much simpler than OT. The cost, that concurrent typing in the same text layer can't merge, is acceptable for a design tool, and you should say so out loud.

Ana sets
Ben sets
First at serverStep 1 of 4

Ana and Ben both see the card as the server has it.

Step 1. Ana sets fill to red, Ben sets fill to blue, and Ben's change reaches the server first. Ana sees fill grey, name Card, x 40. The server has fill grey, name Card, x 40. Ben sees fill grey, name Card, x 40.fillnamexAnagreyCard40servergreyCard40BengreyCard40same property: one value wins
Ana and Ben both see the card as the server has it.

Try the lab: let two users edit the same card. If they change different properties (one the fill, the other the name), both changes survive. Only when both change the same property does one value win, and it is whichever reached the server last, not whoever clicked first. Each user's screen shows their own value until the server's answer arrives, which is the next mechanism.

Applying local edits without flicker

Your own edits must apply at once. But imagine you set the fill to red while Ben's blue is already in flight from the server. The server will order your red after his blue, so red will win. If your client applied Ben's blue when it arrived, your card would flash blue and then back to red when your change came back confirmed.

The rule that prevents the flash: keep a set of unacknowledged (node, property) pairs that you have sent but the server has not yet confirmed. While a pair is in that set, ignore incoming server values for it. Your value is the best guess of the final state, because anything the server sends now was ordered before your change.

ts
const pending = new Map<string, number>(); // "nodeId/prop" -> count of unacked sends

function setLocal(id: NodeId, prop: string, value: unknown) {
  doc.set(id, prop, value);                  // apply now, redraw next frame
  const key = id + "/" + prop;
  pending.set(key, (pending.get(key) ?? 0) + 1);
  socket.send({ t: "set", id, prop, value, seq: nextSeq() });
}

function onServerChange(c: { id: NodeId; prop: string; value: unknown; fromMe: boolean }) {
  const key = c.id + "/" + c.prop;
  if (c.fromMe) {                            // our own change, now ordered: an ack
    const n = (pending.get(key) ?? 1) - 1;
    if (n === 0) pending.delete(key); else pending.set(key, n);
    return;
  }
  if (pending.has(key)) return;              // ours will be ordered later and win
  doc.set(c.id, c.prop, c.value);
}
The client side of per-property last writer wins, simplified to one message per change; the real protocol batches them (see the WebSocket messages later).
A sequence diagram with Ana, the server, and Ben. Ben sends fill blue. Ana sends fill red, which is pending on her client. The server broadcasts blue to Ana, who ignores it because her red is pending. The server acks Ben. The server acks Ana's red, ordered after blue, and broadcasts red to Ben, who shows red. Later Ben sends B into A, then Ana sends A into B. The server would get a cycle, so it rejects Ana's change and sends her the fix.AnaServerBenfill = bluefill = red (pending)blue: ignored, pendingackack: red is orderedremote: fill = redB into AA into Breject: cycle, plus fix
The pending rule is why Ana never sees blue flash on her screen: anything the server sends now was ordered before her red. The rejection path is rare, but it is how the server keeps the tree a tree.

Creating and deleting nodes

Creating a node must work offline and without waiting, so the client makes the ID. Each session gets a unique session number from the server at connect, and IDs are session:counter, like 42:1093. Two clients can never produce the same ID.

Deleting a node removes it and all its properties from the server, so files don't grow forever with dead data. If you undo your delete, your client still has the deleted node's properties in its undo history and re-creates it with the same ID, so comments and links that pointed at it keep working.

Moving nodes between parents, and ordering siblings

Reparenting is where trees get tricky. Treating a move as "delete here, create there" would give the node a new identity and drop anyone's concurrent recolor of it. Instead, as the scene graph chapter set up, a node's parent link and its position among siblings are one property on the node. A move is one property change, which merges like any other: if Ana moves the card into Frame A while Ben moves it into Frame B, it ends in whichever frame the server saw last, never in both.

One case needs the server's help: a cycle. Ana drags Frame A into Frame B while Ben drags Frame B into Frame A. Each change is valid alone, but together they make A and B each other's parent. The server rejects any parent change that would make a cycle.

For the moment before the rejection arrives, Ana's client may hold both changes; the simple answer is to hide the cycle's nodes until the server's verdict puts them back in a valid place. It's rare, it's brief, and it never corrupts the file.

Fractional indexing. Before: three children sorted by key, a0 Header, a1 Pay, a2 Total. After inserting Banner between Header and Pay: a0 Header, a0V Banner, a1 Pay, a2 Total. Only Banner's key is new; nothing else is rewritten.beforea0 Headera1 Paya2 Totalafter inserting Bannera0 Headera0V Bannera1 Paya2 Totalonly Banner's key is new; no sibling is rewritten
Keys sort as strings, and there is always room for another key between two others. With plain integer indexes, inserting at position 1 would rewrite every sibling after it, and two people inserting at once would fight over the same numbers.

The order of siblings is the layer stacking order, and two people reorder layers often. Storing an index like 3 breaks the moment someone inserts above it, because every later index must change. Fractional indexing gives each child a position key that sorts as a string; to insert between keys a0 and a1, generate a key that sorts between them, such as a0V.

Only the moved node changes. Two people inserting into the same gap at once can produce the same key. Figma has a separate post on this technique for ordered sequences, and there the server simply gives the second insert a new, unique position; breaking ties by node ID on every client is another option.

Joining, reconnecting, and going offline

The protocol stays simple because joining is simple. On open, the client downloads a snapshot of the file and then exchanges changes over a WebSocket. After a drop, it does the same: download a fresh snapshot, re-apply the edits it made while disconnected on top, and resume.

All the cleverness lives in handling changes on a connected document; reconnection reuses the open path. Figma's post describes exactly this flow, which lets people keep editing through a disconnect.

Presence: cursors, selections, and following

Cursors, selection outlines, and "who is here" avatars are presence: state about people, not the document. It is high frequency and disposable, so it doesn't belong in the document or the undo history. Send each user's cursor position in page coordinates, current selection IDs, and viewport rectangle on a separate channel, throttled to about 10 to 20 updates a second, and have receivers animate between updates so remote cursors glide.

The viewport makes "follow this person" possible: your camera copies theirs. With 200 viewers in a design review, the server should forward cursors only for active editors, or sample them, so 200 people don't each receive 200 cursor streams. The presence and cursors lesson covers the general pattern; here the twist is that positions are in page coordinates, so each receiver maps them through its own camera.

Check your understanding · Exercise 6

Ana and Ben edit the same button at the same moment. Which outcomes does per-property last writer wins produce? Select all that apply.

Undo and redo when other people are editing too

In a single-player editor, undo is easy: keep a stack of document snapshots or inverse operations and pop. Put five people in the file and the easy version does damage. If undo walks one shared history, pressing Cmd+Z after Ben recolored the logo undoes Ben's work, not yours.

If undo restores a snapshot from before your last change, it also wipes out everything everyone else did since. Both feel like data loss to the people affected. The rule users expect: undo only reverts my own changes, and leaves everyone else's alone.

Decision How should undo work in a shared file?

Snapshot undo

Save a copy of the document (or the touched subtree) before each step; undo restores it.

Pros

  • Trivial to implement in single player.

Cons

  • Restoring a snapshot overwrites other people's later work.
  • Memory heavy on big files.

Shared history undo

Every change from every user goes onto one history; undo reverts the most recent one.

Pros

  • Matches the server's log exactly.

Cons

  • You undo other people's work. Nobody wants this.

Per-user stack of property inverses

Each client records only its own committed steps. A step is a list of (node, property, value before, value after).

Undo writes the 'before' values as brand new changes, which sync like any edit.

Pros

  • Touches only properties you changed.
  • Undo is just another edit, so it merges, syncs, and can itself be undone.
  • Small: a step stores only the properties it changed.

Cons

  • Needs care when other people changed the same properties after you, or deleted the node.

What we pick: A per-user stack of property inverses. Undo becomes "write these old values back", sent through the same per-property path as every other change, so the multiplayer rules from the last chapter still hold. It also makes undo of a delete possible: the deleting client kept the node's properties in its undo step and re-creates the node with the same ID.

Five moments: start, Ana sets red, Ben sets blue, Ana undoes, Ana redoes. The fill everyone sees goes grey, red, blue, grey, blue. Ana's undo stack holds was grey after her edit, is empty after the undo, and holds was grey again after the redo. Her redo stack is empty until the undo, when it records was blue, the value current at undo time. A note says redo was recorded at undo time, so it restores blue, not red.startAna: redBen: blueAna: undoAna: redogreyredbluegreyblueAna's undo stack-was greywas grey-was greyAna's redo stack---was blue-redo was recorded at undo time: it restores blue, not red
Undo then redo returns the document to exactly where it was, including Ben's change. Recording the redo value at undo time is what makes that true.

The redo rule that keeps documents stable

The subtle part is redo. Single-player redo means "put back the values I had". In a shared file that can overwrite what others did in between. Figma's multiplayer post states a guiding principle that settles it: if you undo several times, copy something, and redo back to the present, the document must end up unchanged.

To get that, undo records the redo step at the moment of undo: it stores the values that were current just before the undo, which may include other people's newer values, not the values from your original edit. Redo symmetrically rewrites the undo step with the values current at redo time.

A worked example. The card's fill starts grey. Ana sets it to red: her undo step stores (fill, before grey, after red). Ben then sets it to blue.

Ana presses undo: her client writes grey and records a redo step with "current before undo: blue". Ana presses redo: her client writes blue, not red. The document is back to exactly what it was before her undo, and Ben's blue survived. With naive redo, Ana's redo would have written red and silently replaced Ben's change.

Edge cases worth naming

  • The node was deleted by someone else. Skip that part of the step rather than resurrect a node another person removed on purpose. If nothing in the step still applies, move on to the next step, so a press of Cmd+Z is never silently wasted.
  • Someone changed the same property after you. Two reasonable policies: write your old value anyway (predictable: undo means put back what I changed), or skip properties others have touched since. Pick one and apply it everywhere; the first is simpler and is what this guide assumes.
  • Gestures are steps. A drag, a resize, a burst of arrow nudges, or a plugin run is one undo step, opened at the start of the gesture and closed at the end, as the editing chapter set up.
  • Selection comes back too. Undoing a move should reselect the moved layers, so the user sees what changed. Store the selection with each step, but don't sync it as a document change.
  • Memory. Cap the stack, for example at 200 steps, and drop the oldest. Large deletes store whole subtrees, so a byte budget is safer than a count.

Check your understanding · Exercise 7

Fill is grey. Ana sets it red. Ben sets it blue. Ana presses undo, then redo. With the design in this chapter, which statements are true? Select all that apply.

Components and instances: one change, many places

A design system file defines a Button once and uses it 3,000 times across 40 screens. When the brand color changes, every one of those buttons must change, except where a designer deliberately changed a label to "Delete account" or swapped the icon. That is the job of components (the master definition, which Figma calls the main component) and instances (live copies that follow the master but keep their own overrides).

Decision How should an instance relate to its component?

Copy on placement

Placing a component deep copies its subtree, with a note of where it came from.

Pros

  • Rendering and editing are trivial: it's just more nodes.

Cons

  • Changing the master changes nothing, unless a tool finds and rewrites 3,000 copies, clobbering local edits.

Pure live reference

The instance stores only the component ID and draws the master's subtree at its own position.

Pros

  • Master changes show everywhere instantly, and instances cost almost no memory.

Cons

  • No per-instance text, icon, or color: every button says "Button". Useless for real screens.

Live reference plus per-property overrides

The instance stores the component ID and a list of overrides: for a node inside the component, a property and the value to use instead.

The instance's visible subtree is derived: the master's nodes, with overrides applied on top.

Pros

  • Master changes flow to every instance for every property that isn't overridden.
  • Overrides survive master changes.
  • Each override is stored on the instance as its own entry, keyed by path and property, so it syncs and merges like any other property.

Cons

  • Instance subtrees must be derived and cached, and re-derived when the master changes.
  • Nested instances (an icon instance inside a button instance) make the lookup path longer.

What we pick: Live reference plus per-property overrides. It is the only option that meets both halves of the requirement, and it fits our document model cleanly: an override is a (path, property, value) entry stored on the instance, and each entry syncs as its own property rather than as one shared list, so two people overriding different things on the same instance never conflict.

The main Button component has fill green and label Submit. Instance A has no overrides and shows Submit in green. Instance B overrides the label to Pay now and shows Pay now in green. The label override wins; the fill follows the main component.Button (main)green, SubmitInstance Ano overridesInstance Blabel: Pay nowSubmitgreenPay nowgreenoverrides win; everything else follows the main
Each instance resolves a property by checking its own overrides first and falling back to the main component. Change the main fill and both instances turn; change the main label and only Instance A follows.

Resolving what an instance looks like

Every node inside an instance is identified by a path: the instance's ID plus the ID of the matching node inside the component (and, for nested instances, each instance on the way down). The engine resolves a property by checking the instance's overrides for that path first and falling back to the master. Derived nodes are cached like world transforms and marked dirty when the master or the overrides change.

ts
type OverrideKey = string; // "1:5/fill": path inside the component, then the property

type InstanceNode = SceneNode & {
  type: "INSTANCE";
  componentId: NodeId;
  overrides: Map<OverrideKey, unknown>; // each entry syncs as its own property
};

function resolve(inst: InstanceNode, path: NodeId[], prop: string): unknown {
  const key = path.join(";") + "/" + prop;
  if (inst.overrides.has(key)) return inst.overrides.get(key);
  const master = doc.get(path[path.length - 1]);
  return master ? master[prop as keyof SceneNode] : undefined;
}
Overrides win; everything else comes from the master.

Propagating a master change

Keep a reverse index from component ID to its instances. When the master's fill changes, look up the 3,000 instances and mark their derived subtrees dirty. Don't re-derive all of them right away: re-derive the ones on the current page and in view first, so the screen updates in the same frame, and finish the rest in idle time or when their page opens.

Layout can ripple: if the master button gets wider and instances sit in auto layout frames, those frames re-layout too, so the dirty set grows outward. Budget this work per frame (say 4 ms), and keep going in the next frame rather than freezing the editor for a second.

  • Override follows identity, not position. Overrides are keyed by the component child's ID, so reordering children in the master keeps each override attached to the right layer.
  • A deleted master child leaves its overrides unused. If the master removes the icon, the icon override has nothing to apply to; keep the override stored but unused, so undoing the removal in the master brings the customized icon back intact.
  • Reset is delete. "Reset to main component" removes the override entries, and the master's value shows through again.
  • Libraries across files. Components published from a team library are versioned. Instances in other files don't change the instant the library changes; the file shows that updates are available, and the designer reviews and accepts them. That turns a cross-file live dependency into an explicit, reviewable step.

Check your understanding · Exercise 8

A button component's label is "Button" and its fill is blue. One instance overrides the label to "Pay now". A designer then changes the master's fill to green and its label to "Submit". What does that instance show? Select all that apply.

Architecture: engine, shell, sync, and workers

We now have every piece. This chapter puts them in boxes and draws the lines between them. The guiding principle is one owner per kind of state: the engine owns the document and everything derived from it; React owns UI state like which panel tab is open; the sync client owns what has and hasn't been confirmed by the server; the server owns the truth.

The client architecture. Inside the browser tab: the UI shell (React panels) and Workers (decode, export) at the top; the canvas engine in the middle with Tools, Undo, and the Doc store in one row, and the R-tree and Renderer (WebGL or WebGPU) below; the Plugin host (Wasm JS and iframe) and the Sync client (WebSocket, outbox) at the bottom. The shell sends commands to the store; workers send decoded nodes to the store; tools write through the undo manager into the store; the store feeds the R-tree and the renderer; the plugin host calls into tools; changes flow both ways between the store and the sync client, which talks both ways with the multiplayer server. On the server side, a CDN serves snapshots and images to the workers.Browser tabUI shellReact panelsWorkersdecode, exportCanvas engineToolsUndoDoc storeR-treeRendererWebGL / WebGPUPlugin hostWasm JS + iframeSync clientWebSocket, outboxchangesServersCDNsnapshots, imagesMultiplayerone per file
Every arrow into the Doc store is a command or a change; every arrow out is a notification or a change on its way to the server. The renderer and the R-tree only read, so they can be rebuilt from the store at any time.
PartResponsibilityWhy the boundary is here
UI shell (React)Toolbar, layers panel, properties panel, menus, dialogs, comments.Real DOM gives keyboard access, screen reader support, text inputs, and fast iteration. It never touches pixels on the canvas.
Editor bridgeCommands from the shell to the engine; batched events from the engine to the shell once per frame.A narrow, typed API keeps the shell from re-rendering on every pointer move, and lets the engine be swapped or moved.
Document storeThe flat node map, derived children, cached world transforms and bounds, resolved instances.Single source of truth on the client. Everything else reads from it.
Spatial indexR-tree of world bounds per page.Shared by culling, hit testing, marquee, and snapping.
RendererWebGL 2 or WebGPU backend, tile cache, text and image textures, context loss recovery.Pure function of document plus camera. Owns only caches, so it can be rebuilt at any time.
Tools and gesturesGesture state machines, snapping, keyboard shortcuts.Turn input into property changes; never draw or sync directly.
Undo managerPer-user steps of property inverses.Sits between tools and the store, so every change is recorded the same way.
Sync clientWebSocket, outbox, unacknowledged set, reconnect, presence channel.Isolates the network: the rest of the app sees only "apply this change".
Local persistenceIndexedDB cache of recent file snapshots and the unsent outbox.Survives reloads and short outages without involving the engine.
WorkersSnapshot decoding, image decoding, export, font parsing.Heavy work that is not tied to input runs off the main thread.
Plugin hostSandboxed plugin runtime and its iframe UI.Untrusted code stays behind a permission checked API.

Why keep the engine on the main thread instead of moving all of it to a worker with OffscreenCanvas? Input events, cursor changes, and the panels live on the main thread. A drag in a worker engine would cost a message hop each way per pointer move, and every hit test for hover would need one too.

Heavy work that doesn't sit on the input path, like decoding a 30 MB snapshot or encoding a PNG export, goes to workers. Rendering in a worker is a valid optimization to mention once the main thread's own budget is under control.

Data flow. Load path: the CDN sends snapshot bytes to a worker, which sends node records to the doc store, which sends dirty nodes to the renderer and per-frame events to the panels. Write path: tools set properties in the doc store; the store hands local ops to the sync client; the sync client sends a batch to the multiplayer server; the server sends acks and remote ops back, which the sync client applies to the store; and the server broadcasts to other clients.CDNsnapshot, imagesToolsuser inputPanelsReactWorkerdecodeDoc storenodes + cachesRendererGPU frameSync clientoutbox, pendingMultiplayerorders changesOther clientsbytesnodesdirtyset propper framelocal opsremote opsbatchacksbroadcast
Two paths meet at the doc store. Reads come in as immutable snapshots from the CDN; writes leave as small property batches over the socket, and other people's writes come back the same way.

The data flow, end to end: the client fetches file metadata and permissions over HTTPS, then downloads a compressed binary snapshot of the file. A worker decodes it into node records and hands them to the engine, which builds the store, bulk loads the spatial index, and renders the first page.

Images arrive separately, by content hash, from a CDN with long cache lifetimes, and are decoded with createImageBitmap before upload to the GPU. Then the WebSocket opens. From that point, local edits flow from tools to the store to the outbox to the server, and other people's edits flow from the server through the sync client into the store, which marks nodes dirty for the renderer and tells the shell once per frame.

Keeping React fast next to the engine

The properties panel shows the selection's x, y, width, fill, and so on. During a drag those values change 60 times a second. If the engine pushed every change into a React context that the whole app reads, every panel would re-render every frame and steal the drawing budget.

Instead, the engine exposes a small store with a subscribe function and snapshot getters, and each panel subscribes with useSyncExternalStore to just the slice it shows. The engine notifies at most once per frame. The X field re-renders while you drag; the layers panel, whose tree didn't change, does not.

Think first

A developer proposes keeping the selected node's properties in React state, editing them there, and pushing them to the engine on blur. What goes wrong?

Data model and interface

Most of the data model is already in place from the scene graph chapter. What's left is to say where each kind of data lives on the client, how long it lives, and what goes over the wire. The table is worth sketching in the interview because it shows you separate document state from everything around it.

DataWhere it livesLifetime and sync
Nodes and their propertiesEngine document store (in memory), for loaded pagesSynced through the multiplayer socket; server is the authority.
Derived data: world transforms, bounds, instance subtrees, text layoutEngine cachesNever synced; rebuilt from nodes at any time.
Tiles, textures, glyph atlasGPU memory, under a budgetCache only; dropped on context loss and redrawn.
Camera and current pageEngine, mirrored in the URL (?node-id=...)Per tab; the URL lets a link open a specific frame.
SelectionEngine, published to the shellPer user; shared as presence, never as document state.
Undo and redo stepsUndo manager, in memoryPer tab; lost on reload by design.
Unsent changes (outbox)Memory plus IndexedDBUntil acknowledged; replayed after reconnect or reload.
Recent file snapshotsIndexedDBSpeeds up reopen; refreshed from the server on open.
ImagesHTTP cache via CDN, decoded bitmaps in memoryImmutable, keyed by content hash.
Presence: cursors, viewportsSync client, in memoryEphemeral; dropped on disconnect.
Panel UI state: open tabs, expanded layersReact state and localStoragePer device; not shared.
Where client data lives. Engine memory holds nodes (the client's source of truth), world transforms, the R-tree, instance subtrees, undo steps, and presence. GPU memory holds tiles and glyphs. IndexedDB holds the outbox and recent snapshots. The server holds the file snapshots, the change log, and images by hash, and is the real source of truth.Engine memorynodestransformsR-treeinstancesundo stepspresenceGPU memorytilesglyphsIndexedDBoutboxsnapshotsServer: the truthfile snapshotschange logimages by hash
Only the nodes, the outbox, and the server's copy are truth. Everything else can be thrown away and rebuilt, which is what makes context loss, reloads, and memory pressure survivable.

HTTP endpoints

http
GET /api/files/f_8Kq2
-> 200 { "id": "f_8Kq2", "name": "Checkout v3", "role": "editor",
         "pages": [{ "id": "0:1", "name": "Flows" }, { "id": "0:2", "name": "Components" }],
         "snapshotUrl": "https://cdn.example.com/s/f_8Kq2/1842.bin",
         "snapshotVersion": 1842, "wsUrl": "wss://mp.example.com/f_8Kq2" }

GET https://cdn.example.com/s/f_8Kq2/1842.bin     (compressed binary, immutable)

POST /api/images            Content-Type: image/png   (raw bytes)
-> 201 { "hash": "9f2c...e1", "width": 2400, "height": 1600 }

GET https://cdn.example.com/img/9f2c...e1        Cache-Control: public, max-age=31536000, immutable

Errors: { "error": { "code": "FORBIDDEN", "message": "You need edit access" } }
Plain HTTPS for everything that is not live: metadata, snapshots, images, and exports.

Snapshots and images are immutable and named by version or content hash, so the CDN and the browser can cache them forever and never serve a stale copy. A file that changed gets a new snapshot version, not a modified one. Large files can expose a snapshot per page, so the client downloads the page the user opens first and the others on demand (more on that in the performance chapter).

WebSocket messages

ts
// client -> server
type Join = { t: "join"; fileId: string; fromVersion: number; sessionId?: number };
type Changes = { t: "changes"; seq: number; ops: Op[] };       // one batch per frame at most
type Op =
  | { k: "create"; id: NodeId; props: Partial<SceneNode> }
  | { k: "set"; id: NodeId; prop: string; value: unknown }
  | { k: "delete"; id: NodeId };
type Presence = { t: "presence"; cursor?: [number, number]; selection?: NodeId[]; viewport?: Rect };

// server -> client
type Welcome = { t: "welcome"; sessionId: number; version: number; missed: Op[] };
type Ack = { t: "ack"; seq: number; version: number };          // our batch is ordered
type Remote = { t: "remote"; from: number; version: number; ops: Op[] };
type Reject = { t: "reject"; seq: number; reason: "CYCLE" | "FORBIDDEN" | "INVALID"; fix: Op[] };
type PeerPresence = { t: "peer"; session: number; user: UserLite; presence: Presence };
The live protocol: property changes, acks, presence, and errors. Binary encoded in production.
  • Batching: changes made in one frame go in one changes message. A drag sends at most one batch every 50 ms with the latest transform; there is no value in sending 60 intermediate positions a second.
  • Versions: every ordered batch increments the file version. The simple reconnect path downloads a fresh snapshot; as a refinement for short drops, fromVersion lets the server send only what was missed, or tell the client to reload the snapshot if the gap is too large.
  • Rejections carry the fix: a rejected batch includes the server's current values for what it touched, so the client can roll back without a full reload.
  • Permissions: the server checks role on every batch. A viewer's client hides editing tools, but the server is what actually enforces it.

Check your understanding · Exercise 9

Which data should never be sent as document changes over the multiplayer socket? Select all that apply.

Large files: loading, memory, and performance budgets

Everything so far works on a 2,000 layer file. The design system file with 50,000 layers, 20 pages, and 500 images is where designs fall over, and interviewers like to push there. Three things break: opening takes too long, memory runs out, and some edits (a master component change) touch too much at once. Let's put numbers on each.

Load the page you look at, not the whole file

Downloading, decoding, and indexing all 20 pages before the first paint can take 10 seconds or more on a big file. But the user looks at one page. So the snapshot is split per page, and the client loads the open page first, renders it, and fetches others when the user opens them or in idle time. There is one catch: pages depend on each other.

An instance on the Flows page needs its master component, which lives on the Components page. The server therefore also sends the subtrees of every component used by the open page, so instances resolve without loading the rest of that page. Search, "select all with this style", and plugins that walk the whole document need everything; they trigger a load of the remaining pages first, with a progress indicator.

A memory budget, line by line

ItemRough size (estimate)How to keep it in budget
Node records (50,000 nodes)about 1 KB each with properties, so about 50 MBCompact typed storage; load pages lazily.
Derived caches (transforms, bounds, text layout, R-tree)about the same again, 50 MBOnly for loaded pages; drop text layout for pages not shown.
Decoded imagesa 2400 by 1600 image is 2400 x 1600 x 4 bytes, about 15 MBDecode only visible images, at the resolution needed for the zoom. 500 full size images would be 7.5 GB, so this is the line that kills tabs.
Tile cache on the GPU512 by 512 x 4 bytes is 1 MB per tileCap at about 256 tiles (256 MB), evict least recently used.
Undo historya few KB per step, more for big deletesByte budget, oldest steps dropped first.

The images row is the lesson: a 400 KB JPEG on the network becomes 15 MB once decoded. Keep encoded bytes in the HTTP cache, decode with createImageBitmap (which can also downscale while decoding through its resizeWidth and resizeHeight options, supported in current Chrome, Firefox, and Safari), hold decoded bitmaps in a least recently used cache with a byte limit, and call close() on bitmaps you evict so their memory is freed at once.

If the engine is compiled WebAssembly, remember that 32-bit WebAssembly memory tops out at 4 GB, and the browser may kill the tab well before that. The memory leaks lesson covers how to find what's holding memory when this goes wrong.

Spreading big edits across frames

Some edits are inherently big: changing a master used 3,000 times, pasting 5,000 layers, or a plugin renaming every layer. Applying them in one go blocks the main thread for hundreds of milliseconds, and the editor freezes. Apply the document change at once (it's cheap: properties in a map), but schedule the derived work, such as instance re-resolution, layout, index updates, and re-rasterizing tiles, in slices of about 4 ms per frame, visible content first. The screen catches up in a few frames, and the pointer never stops responding.

Performance budgets and how to watch them

MomentBudgetWhat protects it
Open a large file to first page drawnunder 3 s on a good connectionPer page snapshots, worker decode, bulk loaded index, CDN cached snapshots.
Pan and zoom60 fps: under 16.7 ms per frame, drawing under 8 to 10 msCulling, tile cache, level of detail, batching.
Hover hit testunder 1 msR-tree broad phase, exact narrow phase on a handful of nodes.
Drag: input to pixelsnext frameLocal apply, one update per frame, no React in the path.
Remote edit visible to othersabout 200 ms at the medianBatches every 50 ms, small binary messages, one server process per file.
Panel interactions (clicks, typing)INP (Interaction to Next Paint) under 200 msSelective subscriptions, no full panel re-renders.

Measure in the field, not only on a fast laptop. Time each frame's phases (input, update, render) with performance.now() and report percentiles bucketed by file size and device class.

Use the Long Animation Frames API (PerformanceObserver with type long-animation-frame, available in Chromium browsers) to catch frames that ran long and see which scripts caused them. Track dropped frames during pan and drag as a first class metric, since that is what designers feel. The runtime performance lesson covers the tooling in depth.

Check your understanding · Exercise 10

A 50,000 layer file with 500 large images crashes the tab on 8 GB laptops. Which changes address the cause? Select all that apply.

Layers panel, properties panel, and accessibility

The panels are where the canvas meets ordinary web UI, and they carry most of the app's accessibility. They have their own scale problem: the layers panel is a tree of up to 50,000 nodes, and the properties panel must show values for one or 300 selected layers.

A layers tree with 50,000 nodes

Rendering one DOM row per node would put 50,000 rows in the panel and make every expand or rename slow. Instead, flatten the tree into the list of visible rows: a node is visible if all its ancestors are expanded. That list is often a few hundred rows even for huge files, and it changes only when something is expanded, collapsed, added, removed, or moved.

Then virtualize it: with fixed 32 px rows, only the 30 to 40 rows in view (plus a few above and below) exist in the DOM, and scroll position maps directly to a row index. The virtualization lesson covers the mechanics; the tree specific parts are below.

  • Reveal on select. Clicking a shape on the canvas expands its ancestors in the panel, recomputes the visible rows, and scrolls the row into view.
  • Drag to reorder and reparent. Dropping a row between two siblings computes a fractional index between their keys; dropping onto a frame makes it the new parent. Both are the same single property change that multiplayer already handles.
  • Tree semantics that survive virtualization. Use role="tree" with role="treeitem" rows carrying aria-level, aria-expanded, and aria-selected. Because most rows aren't in the DOM, also set aria-setsize and aria-posinset so a screen reader can say "item 3 of 12, level 2".
  • Keyboard. Up and Down move between rows, Right expands or moves into the first child, Left collapses or moves to the parent, Enter renames, and Space toggles selection, following the WAI-ARIA tree view pattern. Keep one focusable row with a roving tabindex, and keep the focused row rendered even when it scrolls out of view, or focus is lost.

A properties panel for one or many

With one layer selected, the panel shows its values. With many, each field shows the common value or the word "Mixed". Typing into a mixed width field sets every selected layer's width; one undo step, one batch.

Number fields should accept arithmetic (120*2) and allow scrubbing: dragging horizontally on the field's label changes the value, using pointer capture like a canvas drag, with live preview on the canvas and a single commit at the end. Every field is a real labelled input, which makes the panel the most accessible way to make precise edits.

Accessibility of the canvas: be honest

A canvas is a bitmap. Screen readers see one element with no content, and the only built-in way to expose what is drawn is fallback DOM content inside the canvas element, which you must build and keep in sync yourself. A fully accessible visual design tool is an open problem for the whole industry, and claiming otherwise in an interview is a red flag. What a good design can do:

  • Make everything keyboard reachable. Every tool, every command in menus and a command palette, and canvas navigation with keys: for example Tab and Shift+Tab to move through sibling layers, Enter to select children, and Shift+Enter to select the parent (the shortcuts Figma uses), plus arrows to nudge.
  • Treat the layers panel as the accessible view of the canvas. It is a real tree; selection there and on the canvas are the same state.
  • Announce what changes. A polite aria-live region says "Selected Button, frame, 120 by 40" when selection changes from the keyboard, and "Ben joined" for presence, without announcing every remote edit.
  • Respect settings. Honor prefers-reduced-motion for animated zoom to fit, keep selection and focus outlines at least 3 to 1 contrast against the canvas, and let users scale the UI.
  • Give the canvas a name. role="application" or a labelled region with a short description of the keyboard model, and fallback content explaining how to reach the layers panel.

Check your understanding · Exercise 11

The layers panel is virtualized and shows a tree of 50,000 nodes. Which details are needed for it to work well with a keyboard and screen reader? Select all that apply.

Plugins, export, and never losing work

Plugins: running strangers' code next to the document

A plugin might rename layers, generate a chart, or check color contrast. It needs to read and change the document, quickly and in large amounts. It must not read the user's session cookies, call our private APIs as the user, draw fake login dialogs over the editor, or freeze the tab. Running plugin code with eval in the page would grant all of that, so it is out.

Decision Where should plugin code run?

A sandboxed cross-origin iframe

Load plugin code in an iframe with the sandbox attribute on another origin. It talks to the editor only through postMessage.

Pros

  • The browser's own isolation; well understood (see the clickjacking and iframes lesson).

Cons

  • Every document read is an async message round trip. Walking 50,000 nodes becomes a slow, awkward API.
  • The plugin works on a copy, so it must sync changes back in bulk.

Same-thread JavaScript isolation (Realms style)

Run plugin code on the main thread in a separate global environment, with only a whitelisted API object reachable.

Pros

  • Synchronous, fast document access.

Cons

  • Isolation depends on a JavaScript library closing every escape route; one bug lets plugin code reach the real page.

A JavaScript interpreter compiled to WebAssembly

Run plugin code inside a small JS engine (such as QuickJS) compiled to WebAssembly (Wasm). The only way out is the functions the host explicitly passes in.

Pros

  • Synchronous API like option 2, but isolation comes from the Wasm memory boundary, not from careful JavaScript.
  • Host can limit memory and interrupt runaway loops.

Cons

  • Slower than the browser's JIT for heavy computation.
  • A second engine to ship and keep updated.

What we pick: Run plugin logic in a JavaScript interpreter compiled to WebAssembly, and plugin UI in a sandboxed iframe that talks to the logic through messages. That is where Figma ended up after trying the others: its plugin system post describes an iframe attempt, a Duktape-in-Wasm prototype, and a shipped Realms-based sandbox, and its 2019 security update explains that after vulnerabilities were found in the Realms shim, it switched to QuickJS compiled to WebAssembly.

The plugin sandbox. Inside the editor page: the Doc store, the Plugin API with checked calls, and a Wasm sandbox holding QuickJS running the plugin's main code, with no DOM and no cookies. Calls go from QuickJS to the Plugin API and from there to the doc store through the same path as user edits. The plugin's UI runs in an iframe with an opaque (null) origin and exchanges messages with the plugin code. Network requests go only to allowed domains.Editor pageDoc storePlugin APIchecked callssame path as editsWasm sandboxQuickJSplugin main codeno DOM, no cookiesiframe, null originPlugin UIHTML, buttonsmessagesNetworkallowed domainsfetch
The plugin's code can only reach what the host hands into the interpreter. Its UI can draw buttons and forms, but in an iframe with an opaque origin, so it can't read the editor's page or cookies.
  • Narrow API. The plugin sees a figma-like object with node read and write functions. Every write goes through the same command path as a user edit, so it syncs, respects permissions, and becomes one undo step per plugin run.
  • Declared network access. The plugin's manifest lists the domains it may contact, and the host enforces that list (Figma does it with a Content Security Policy and shows the list on the plugin's public page), so users and reviewers can see where data can go.
  • Budgets. Interrupt plugin code that runs longer than a few seconds without yielding, and cap its memory, so a bad loop can't freeze the editor.

Export: reuse the renderer, off the main thread

PNG and JPG export must look exactly like the canvas, so they use the same renderer, pointed at an OffscreenCanvas in a worker instead of the screen, at the requested scale. convertToBlob() produces the file. A 4x export of a 3,000 by 2,000 frame is 12,000 by 8,000 pixels, which can exceed the GPU's maximum texture size (8,192 pixels a side on many devices) or a browser's canvas size limit, so large exports are rendered in tiles and stitched.

SVG export is a different path: walk the node subtree and write rect, ellipse, path, and text elements with transforms, gradients, and masks; effects SVG can't express are rasterized into an embedded image. PDF follows the SVG path through a vector PDF writer. Multi-layer exports are zipped in the worker and handed over as a Blob download.

Autosave and short outages

There is no save button. Each change is in the outbox within one frame and on the wire within about 50 ms. The risky moments are the gaps: a dropped connection, a closed laptop, a tab reload. The design covers them in layers:

  1. Persist the outbox. Write unacknowledged changes to IndexedDB in small batches. After a crash or reload, the client re-sends them after loading the snapshot.
  2. Show honest status. "Saved", "Saving...", or "Offline: changes kept on this device" in the title bar, driven by the outbox length and socket state.
  3. Warn before closing with unsent work. A beforeunload handler that only triggers when the outbox is not empty.
  4. Reconnect simply. Download a fresh snapshot, re-apply local unsent changes on top, then resume the socket, as the multiplayer chapter described.
  5. Keep version history on the server. Periodic checkpoints let a team restore yesterday's file if someone's long offline session overwrote things with last writer wins. The offline-first lesson covers queues and conflict handling in more depth.

Check your understanding · Exercise 12

Why does a design tool run plugin logic in a WebAssembly-compiled JS engine rather than directly in the page? Select all that apply.

Running it in the interview

This question is wide enough that you can spend an hour on any one chapter. The interviewer is watching whether you pick the decisions that matter, put numbers on them, and go deep where they push. A plan for 60 minutes:

MinutesWhat you doWhat to draw or say
0 to 5Scope: file size, frame budget, how many editors, plugins, offline.Write the numbers: 50,000 layers, 60 fps, 5 to 50 editors. "Drawing gets 8 to 10 ms a frame."
5 to 12Two worlds and the architecture.The wireframe, then engine, shell, sync, workers as boxes with arrows.
12 to 20Scene graph.Flat map of nodes, relative transforms, parent and position as one property. Explain why that shape helps sync.
20 to 30Rendering and hit testing.GPU renderer, camera matrix, culling, tiles; R-tree broad phase plus exact narrow phase.
30 to 42Multiplayer and undo.Per-property last writer wins with server order, unacknowledged set, fractional indexing, per-user undo.
42 to 52Wherever they push: components, large files, plugins, accessibility.Pick the one they show interest in and go deep with numbers.
52 to 60Wrap up and follow-ups.Summarize the three biggest trade-offs and what you'd measure in production.

Draw the wireframe first, because it gives you and the interviewer shared names for the layers panel, the canvas, and the properties panel. Then draw the architecture with the document store in the middle, and keep pointing at it: every later answer is "this is a property change that goes through the store". That one sentence ties rendering, sync, undo, components, and plugins together, and it is the clearest sign that your design hangs together.

Follow-up questions and strong answers

Think first

Two people often edit the same long text block. How would you support that?

Think first

Isn't WebGL overkill? Why not Canvas 2D?

Think first

A designer works offline on a plane for 6 hours. What happens when they land?

Think first

A design review has 5 editors and 500 viewers. What changes?

Think first

Users say the editor "feels slow". How do you find out why?

Check your understanding · Exercise 13

You have 10 minutes left and the interviewer asks about components. Which answer shows senior depth? Select all that apply.

Summary

  • Two worlds: a canvas engine that owns the document, drawing, and hit testing, and a React shell for panels, connected by a narrow bridge that updates the shell at most once per frame.
  • Model the document as a flat map of nodes, with transforms relative to the parent, and parent plus position stored as one property. Derived data (world transforms, bounds, instance subtrees) is cached and rebuilt lazily.
  • Render on the GPU and do less work per frame: render on demand, cull with a spatial index, cache tiles, redraw dirty rectangles, and lower detail when zoomed out.
  • Hit test in two phases: an R-tree broad phase for candidates, then an exact test in each node's local space. Handles come first and are sized in screen pixels.
  • Treat every gesture as a state machine: absolute deltas from the start, one update per frame, throttled sync, one undo step, and a clean cancel.
  • Merge with server-ordered last writer wins per property, ignore server values for properties you have unacknowledged changes to, and order siblings with fractional indexing.
  • Undo only your own changes, as new property writes, and record redo values at undo time so undo then redo never changes the document.
  • Plan for big files with lazy page loading, a line-by-line memory budget (decoded images dominate), and big edits spread across frames.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next