cf.completefrontend
My progress
SYSTEM DESIGN · #120

Design a web photo editor

A layered photo editor in the browser, like Photopea or Photoshop on the web: layers with masks and blend modes, adjustments that never destroy the original pixels, brushes that keep up with a pen, and 24 MP photos that need about 96 MB per layer. The hard parts are the document model, a GPU rendering pipeline with enough color precision, tiling so memory and texture limits hold, cheap undo for pixel edits, and saving and exporting without freezing the page.

The question

Design a photo editor that runs in the browser. People open a large photo, add layers, adjust brightness, curves and color without losing the original, paint and erase with a mouse or a pen, make selections, undo anything, and export a JPEG or keep working on the project later.

Walk me through how you model the document, how pixels get from the layers to the screen, how you handle a 24 megapixel photo with ten layers on a laptop, and how undo, saving and export work.

Real-life examples: Photopea, Photoshop on the web, Pixlr, Canva and Adobe Express photo editing, The Google Photos web editor, Squoosh for export and compression

Requirements: a desktop app's job inside a browser tab

A photo editor is a strange thing to build for the web. Desktop editors like Photoshop or Affinity Photo assume many gigabytes of RAM, a native GPU API, and direct access to files. A browser tab gets a share of memory the browser decides, a GPU it reaches through WebGL or WebGPU, and files through a picker.

Products like Photopea, Pixlr, Photoshop on the web and the Canva and Adobe Express editors show it can be done. The design is about fitting a heavy workload into those limits.

This guide focuses on what is special about a photo editor. Several neighbours are covered in their own guides, and we link them instead of repeating them: decoding files, EXIF orientation and crop math are in the image-cropper guide; canvas rendering, hit testing and zoom around the pointer are in the figma guide; history stacks and command patterns are in the undo-redo guide; local-first storage is in the excalidraw guide; color spaces and pickers are in the color-picker guide.

Questions to ask

QuestionAnswer we assumeWhat it changes
Quick fixes or a full layered editor?A layered editor: image, adjustment, text and shape layers, masks, blend modes.We need a real document model, not one canvas with filters applied.
How big are the photos?Typically 12 to 24 MP from phones and cameras. Up to 50 MP from high-end cameras.A 24 MP layer is 96 MB of RGBA pixels. Memory decides the architecture.
How many layers?Typically 3 to 10, up to about 50 on big projects.Ten full layers at 24 MP is about 1 GB. We must tile and evict.
Must edits be reversible later?Yes, adjustments stay editable after saving and reopening.Non-destructive adjustment layers, stored as parameters, not baked pixels.
Pen input?Yes: pressure on a Wacom tablet, Apple Pencil on iPad, Surface Pen.Pointer events with pressure and coalesced events, low-latency brush path.
Which devices?Laptops and desktops first. iPads are a stretch goal. Phones get a simpler editor.We can assume WebGL2, but we must budget memory for 8 GB laptops.
Where do files live?Locally first, with optional cloud save of the project file.Autosave to IndexedDB or OPFS. Cloud save is a file upload, not live sync.
Collaboration?No live co-editing in v1.We avoid the CRDT and multiplayer work in the figma guide.
Which formats?Open JPEG, PNG, WebP, HEIC where possible, and PSD. Export JPEG, PNG, WebP, AVIF.PSD parsing in a worker, encoders that may need WebAssembly.

Functional requirements

  • Open a photo or a project file. Show it fitted to the screen, then zoom from 1% to 3200% and pan.
  • Layers panel: add, delete, reorder, group, hide, rename. Each layer has opacity, a blend mode, and an optional mask.
  • Adjustment layers: brightness and contrast, levels, curves, hue and saturation, blur. Editable at any time, they change the look of the layers below them without touching their pixels.
  • Paint, erase and clone with brushes of any size, hardness and opacity, with pen pressure.
  • Selections: rectangle and ellipse marquee, freehand lasso, and a magic wand that picks similar colors. A selection limits where paint and adjustments apply.
  • Text and shape layers that stay editable as text and vectors.
  • Undo and redo for every action, including brush strokes, with a history panel.
  • Autosave the project locally. Export a flat image as JPEG, PNG, WebP or AVIF with a quality slider and a size estimate. Import and export PSD.

Non-functional requirements

  • Brush at the display rate: ink appears under the pen within one or two frames, so about 16 to 33 ms at 60 Hz. No dropped frames while painting.
  • Slider feedback: dragging an adjustment slider updates the canvas every frame, even on a 24 MP photo.
  • First paint fast: a 24 MP JPEG shows something within about 1 second of opening, at full detail within about 3 seconds on a mid-range laptop.
  • Memory: stay under about 1.5 GB of combined CPU and GPU memory for a 24 MP, 10 layer document on an 8 GB laptop, and never crash the tab.
  • No data loss: at most a few seconds of work lost if the tab crashes or the laptop sleeps.
  • Quality: no visible banding after stacking several adjustments; colors match what the export will contain.
  • Accessible where it can be: every panel, slider and command works with a keyboard and a screen reader. We are honest that painting on a canvas is visual work.

Out of scope

  • Live multi-user editing. It needs the operation sync and presence work covered in the figma guide.
  • RAW camera files. Decoding them is a large codec project of its own, so we assume JPEG, PNG, WebP, HEIC and PSD.
  • AI features like generative fill or background removal. They are a server call that returns a new layer, so they plug into the model without changing it.
  • Print workflows: CMYK and spot colors. We work in RGB and say so.
Wireframe of the photo editor. A window titled Lisbon.jpg. On the left, a tool column with brush B, eraser E, marquee M, magic wand W and text T; the brush is active. Across the top, the tool options bar reads brush 40 px, opacity 50%. In the middle, the canvas, drawn with WebGL from tiles, at zoom 50%. On the right, the Adjustments panel with curves and levels, and below it the Layers panel listing Title, Curves, which is selected, and Photo.Photo editor: Lisbon.jpgBEMWTbrush 40 px, opacity 50%canvas: WebGL, tileszoom 50%Adjustmentscurves, levelsLayersTitleCurvesPhoto
Everything around the canvas is ordinary DOM: buttons, sliders and lists that React renders and a screen reader can use. Only the canvas in the middle is pixels, drawn by the render engine.

Check your understanding · Exercise 1

Which answers to the scope questions change the architecture the most? Select all that apply.

What makes a photo editor hard

Most frontend designs are about data: fetch it, cache it, show it. A photo editor is about pixels, and pixels behave differently. Five properties drive every decision in the rest of this guide.

  1. The data is huge and dense. A 24 MP photo is 6000 by 4000 pixels. At 4 bytes per pixel (red, green, blue, alpha) that is 96,000,000 bytes, about 96 MB, for one layer. A JSON feed of 1,000 posts is maybe 2 MB. Every copy, every undo snapshot, every upload to the GPU costs real memory and real time.
  2. The output is computed, not stored. What you see is the result of stacking layers top to bottom, through masks, opacities, blend modes and adjustments. Change one slider and the whole stack may need to be recomputed. That is 24 million pixels times the number of steps, per frame.
  3. Edits must stay reversible. People expect to change the curve they set an hour ago, and to undo a brush stroke. If every edit overwrote pixels, both would need full copies of old pixels. The model has to separate what the user did from the pixels it produced.
  4. Input is fast and precise. A pen often reports positions 200 times per second or more, with pressure. If the brush lags by 100 ms or skips points, the stroke looks broken. The paint path must be short and never wait on the main thread's other work.
  5. The browser sets the limits, not us. WebGL and WebGPU cap texture sizes. Browsers cap canvas sizes and tab memory, and the limits differ by device. On a phone, a tab that grows too large is killed without warning. The design must degrade, not crash.

Put together, four rules follow: keep pixels in as few copies as possible; do heavy pixel math on the GPU or in workers, never in a React render; store edits as a description plus the original pixels; and split images into tiles so we only pay for what is visible and what changed. Each of the next chapters applies these rules to one part of the editor.

The numbers to keep in your head. 6000 by 4000 divided into 512 pixel tiles rounds up to 12 columns and 8 rows.
QuantityValueWhy it matters
One 24 MP RGBA layer, 8 bits per channel96 MBThe unit of memory cost for everything else.
Same layer at 16 bits per channel192 MBBetter quality costs double.
Same layer at 32-bit float per channel384 MBRarely worth it for every layer.
Ten 8-bit layers960 MBAlready near the budget before any undo history.
One 512 by 512 RGBA tile1 MBThe unit we load, upload, evict and diff.
Tiles in one 24 MP layer12 by 8 = 96 tilesOnly the visible ones need to be on the GPU.
Frame budget at 60 Hz16.7 msCompositing, brush and UI must all fit.

Think first

Why not keep one big canvas element per layer, apply CSS filter for adjustments and CSS mix-blend-mode for blending, and let the browser composite?

Check your understanding · Exercise 2

A 6000 by 4000 photo with 6 layers, all 8 bits per channel. Which statements are true? Select all that apply.

The document model: a tree of layers, and edits as data

Everything else hangs off the document model, so we design it first. The question is simple to state: when the user does something, what do we store? The answer decides how undo works, how saving works, what the renderer must compute each frame, and how much memory we use.

The naive model: one bitmap, edited in place

The simplest editor holds one ImageData (the browser's raw RGBA pixel array) for the photo. Brightness plus 20 loops over every pixel and adds 20; contrast then loops again over the result. This is how many quick filter apps work, and for one tweak it is fine.

Now follow a real session. The user raises brightness by 40, which clips the bright sky to pure white, then lowers it by 40. The sky comes back as one flat light gray, every clipped pixel at the same 215: the detail was thrown away at step one, because 8-bit values can't go above 255.

Every destructive edit loses a little. Ten edits later the photo has visible banding and lost highlights, and the only fix is undo, which needs a full 96 MB copy per step. Changing the curve you set ten steps ago is impossible without undoing the nine steps after it.

Decision How should the document store edits?

Destructive: edit pixels in place

Each tool reads the layer's pixels, computes new ones, and writes them back.

Undo keeps copies of the old pixels.

Pros

  • Simple to build and to reason about.
  • Rendering is cheap: just show the pixels.
  • Matches how painting naturally works.

Cons

  • Quality loss accumulates with every adjustment.
  • Old adjustments can't be changed later.
  • Undo needs pixel copies, so memory grows fast.

Fully non-destructive: originals plus a list of operations

Keep the original pixels untouched. Store every edit as a parameterized operation.

To show the image, replay all operations from the original every time.

Pros

  • Any edit can be changed, reordered or removed at any time.
  • No accumulated quality loss.
  • Undo is cheap for parameter edits.

Cons

  • A brush stroke as an operation means replaying thousands of strokes per frame.
  • Render cost grows with history length.
  • Painting and cloning don't fit well.

Hybrid: layers of pixels plus live adjustment layers

Pixel layers hold pixels, and painting writes into them (destructive, but only inside that layer).

Adjustments, masks, opacity, blend modes, transforms, text and shapes are stored as parameters and rendered live.

Users who want reversible painting paint on a new layer.

Pros

  • Render cost depends on layer count, not history length.
  • Adjustments stay editable forever.
  • Matches what Photoshop and Photopea users already know.

Cons

  • Painting is still destructive inside a layer, so undo still needs pixel data.
  • The renderer must composite every layer, every frame something changes.

What we pick: The hybrid. It is the model Photoshop users already understand, and it puts the cost in the right place: rendering scales with the number of layers, which is small, not with the number of edits, which grows all day. Paint strokes touch pixels in one layer, and we make their undo cheap with tile diffs (see the history chapter). Everything else is parameters.

Layer kinds

KindWhat it storesHow it renders
RasterTiled pixels, plus bounds in document space.Sample the tiles, apply the transform.
AdjustmentAn operation and its parameters, like curves with 5 control points.Takes everything composited below it in its group and runs a shader on it.
TextThe string, font, size, color, box.Rasterized into a cached texture, re-rasterized when edited or zoomed.
ShapeA vector path, fill and stroke.Rasterized at the current zoom, so edges stay crisp.
GroupChildren, plus its own opacity, blend mode and mask.Composite children into an offscreen buffer, then blend that buffer.

Every layer also has the same common fields: opacity from 0 to 1, a blendMode such as normal, multiply, screen or overlay, a visible flag, an optional mask, and a transform. A mask is a grayscale image the size of the layer: white keeps the layer, black hides it, gray is partly see-through. Masks are the non-destructive version of the eraser. Erase the sky with the eraser and the sky pixels are gone; paint black on a mask and the sky is only hidden, and painting white brings it back.

The document as a tree of layers. The Document, 6000 by 4000, has four children, listed as the layers panel shows them from top to bottom: Title group, a group; Curves, an adjustment layer; Hue and saturation, an adjustment with a mask; and Photo, a raster layer with 96 tiles. The Title group has two children: the text layer Lisbon and the shape layer Banner. A note says the renderer draws from the bottom up: Photo, then Hue and saturation, then Curves, then the group.Document6000 x 4000Title groupgroup, 90%CurvesadjustmentHue/Satadj + maskPhotoraster, 96 tilesLisbontextBannershapedrawn bottom to top:Photo, Hue/Sat, Curves,then the group as one unittop of the layers list = drawn last
Only the Photo layer and the mask hold pixels. Curves and Hue/Sat are a few numbers each, and the text and shape are re-drawn from their data. The group is composited on its own first, then blended as one unit.

The document is a tree, not a flat list, because groups exist. A group composites its children into an offscreen buffer first, then blends that result into what is below it. That changes the result: an adjustment inside a group only affects the layers below it in that group, and a group set to 50% opacity fades its children as one unit instead of each one separately.

Order matters too. Curves above hue and saturation is not the same as hue and saturation above curves, because each works on the output of the one below.

ts
type BlendMode = "normal" | "multiply" | "screen" | "overlay" | "darken" | "lighten";

type LayerBase = {
  id: string;
  name: string;
  visible: boolean;
  opacity: number;            // 0..1
  blendMode: BlendMode;
  mask?: TiledBitmap;         // 1 channel, 0 = hidden, 255 = shown
  transform: [number, number, number, number, number, number]; // 2D affine
};

type Adjustment =
  | { op: "brightnessContrast"; brightness: number; contrast: number }
  | { op: "curves"; points: [number, number][] }   // 0..1 in, 0..1 out
  | { op: "levels"; black: number; gamma: number; white: number }
  | { op: "hueSaturation"; hue: number; saturation: number; lightness: number }
  | { op: "gaussianBlur"; radiusPx: number };

type Layer =
  | (LayerBase & { kind: "raster"; pixels: TiledBitmap })
  | (LayerBase & { kind: "adjustment"; adjustment: Adjustment })
  | (LayerBase & { kind: "text"; text: string; font: string; sizePx: number; color: string })
  | (LayerBase & { kind: "group"; children: Layer[] });

type TiledBitmap = { width: number; height: number; tileSize: 512; tiles: Map<string, string> }; // "col,row" -> tile id
The core of the document model. Pixels are referenced by tile id, never stored inline.

Two details in this code carry a lot of weight. First, adjustments are plain data: a few numbers. Saving them costs bytes, changing them costs nothing, and the renderer turns them into GPU work.

Second, a raster layer does not hold pixels directly. It holds a map from tile position to tile id, and the tile store owns the bytes. That indirection is what later makes cheap undo, sharing tiles between history states, and loading tiles lazily possible.

Model
One sky pixel with value 230 followed through the adjustment layers: photo pixel 230, bright +50 280, contrast 242, bright -50 192, on screen 192. The photo still holds 230.230photo pixel280bright +50242contrast192bright -50192on screenapplied left to right, from the original every framephoto still holds 230pixels never changeundo cost: a few byteson screen: 192layers on: 3 of 3math in float, clamp once
Live mode: three adjustment layers above the photo, applied bottom to top. Toggle or reorder them.

Check your understanding · Exercise 3

A user applies curves, then hue and saturation, then a blur, all as adjustment layers above one photo layer. Which statements are true? Select all that apply.

The rendering pipeline: GPU compositing and color precision

The document is a description. The renderer turns it into pixels on the screen, and the same renderer, at full resolution, produces the export. Its job is compositing: start from a transparent or white background, then for each layer from the bottom up, compute the layer's pixels, apply its mask and opacity, and blend them onto what is below with its blend mode. Adjustment layers are the special case: they read the result so far and replace it with an adjusted version.

Blend modes are small per-pixel formulas on values from 0 to 1. With a the layer below and b the layer on top: normal is b; multiply is a * b, which only darkens; screen is 1 - (1 - a) * (1 - b), which only lightens; overlay multiplies in the darks and screens in the lights. Then opacity and mask mix the blended color with a. These are cheap per pixel, but there are 24 million pixels per layer, so where the math runs is the real question.

Decision Where do we run compositing and adjustments?

Canvas 2D

Draw each layer with drawImage. Use globalCompositeOperation for blend modes like multiply and screen, and globalAlpha for opacity.

Use the context's filter property with CSS filter functions such as brightness() and blur() for adjustments.

Pros

  • Smallest amount of code. Blend modes are built in.
  • Works everywhere, and it can run in a worker with OffscreenCanvas.

Cons

  • No curves, levels or custom adjustments: only what CSS filters offer.
  • 8 bits per channel only in most browsers, so stacked adjustments band.
  • Browser filter output can differ slightly between engines, so export and screen may not match across browsers.

WebGL2 or WebGPU shaders

Upload tiles as textures. Each adjustment and blend is a small fragment shader: a function that runs once per output pixel on the GPU.

Composite into offscreen framebuffers, then draw the result to the visible canvas.

Pros

  • Thousands of GPU cores: a 24 MP adjustment takes a few milliseconds, so sliders update every frame.
  • Any adjustment we can write as math, including curves through a lookup texture.
  • Half-float textures give precision beyond 8 bits.

Cons

  • More code: texture management, shaders, context loss handling.
  • Texture size and GPU memory limits, so we must tile.
  • Reading pixels back to the CPU is slow, so CPU tools must avoid round trips.

CPU in workers, with WebAssembly

Keep pixels in typed arrays. Run filters in a pool of Web Workers, written in C++ or Rust and compiled to WebAssembly, using SIMD.

Send finished tiles to the main thread to draw.

Pros

  • Exact, deterministic results on every device.
  • Reuses existing native imaging code, which is how Adobe brought Photoshop's C++ codebase to the web with Emscripten.
  • Fits algorithms that are hard on a GPU: flood fill, histograms, codecs.

Cons

  • Even with 8 threads and SIMD, a 24 MP blur takes tens to hundreds of milliseconds: too slow for a slider at 60 fps.
  • Multithreaded WebAssembly needs SharedArrayBuffer, which needs cross-origin isolation headers.
  • Copies to the GPU for display still cost time.

What we pick: WebGL2 shaders for everything the user sees live: compositing, blend modes, masks and adjustments. WebGL2 runs on nearly every current browser, while WebGPU only reached all three engines recently (Chrome 113 in 2023; Safari 26 and Firefox 141 on Windows in 2025), so we add a WebGPU backend later, behind the same renderer interface. CPU workers with WebAssembly handle the jobs a GPU is bad at: the magic wand's flood fill, histograms for the levels panel, PSD parsing, and export encoding. Canvas 2D stays for small things like thumbnails in the layers panel.

The render pipeline on the GPU, read from the bottom up. In the left column, Photo tiles, stored as RGBA8 textures, feed the Hue and saturation pass, which writes an RGBA16F buffer, which feeds the Curves pass, a lookup table. In the right column, the text and shape layers are rasterized into a Group buffer. The Curves output and the Group buffer meet in the Composite, where the group is blended with screen at 90%. The Composite goes to the Display pass, which dithers to 8 bits and draws the Canvas on screen. Notes say only dirty, visible tiles are re-run, and the result below the active layer is kept as a cache.GPU (WebGL2)Photo tilesRGBA8 texturesHue/Sat passRGBA16F outCurves passLUT lookupText + shaperasterizedGroup bufferchildren firstCompositescreen, 90%Display passdither to 8-bitCanvason screenonly dirty andvisible tilesare re-runbelow cache:Photo to Curveskept as one
Every arrow is a texture handed to the next shader, so nothing leaves the GPU until the final draw. Precision is high in the middle (RGBA16F) and drops to 8 bits only once, in the display pass.

What an adjustment looks like as a shader

A fragment shader runs for each output pixel. It reads the input texture at that position, does math, and writes a color. Here is brightness and contrast, plus curves done with a lookup table (LUT): a 256 by 1 texture where entry i holds the output for input i. The curve editor computes the LUT on the CPU when control points change, which is cheap, and the shader samples it at texel centres so input 0 and input 1 land exactly on the first and last entries.

text
#version 300 es
precision highp float;
uniform sampler2D uInput;     // composite of everything below
uniform sampler2D uCurveLut;  // 256 x 1, one curve per channel
uniform float uBrightness;    // -1..1
uniform float uContrast;      // -1..1
in vec2 vUv;
out vec4 outColor;

const float LUT_SCALE = 255.0 / 256.0;  // map 0..1 onto texel centres
const float LUT_OFFSET = 0.5 / 256.0;

void main() {
  vec4 c = texture(uInput, vUv);
  vec3 rgb = c.a > 0.0 ? c.rgb / c.a : c.rgb;          // un-premultiply
  rgb = rgb + uBrightness;
  rgb = (rgb - 0.5) * (1.0 + uContrast) + 0.5;
  rgb = clamp(rgb, 0.0, 1.0);
  vec3 u = rgb * LUT_SCALE + LUT_OFFSET;
  rgb.r = texture(uCurveLut, vec2(u.r, 0.5)).r;
  rgb.g = texture(uCurveLut, vec2(u.g, 0.5)).g;
  rgb.b = texture(uCurveLut, vec2(u.b, 0.5)).b;
  outColor = vec4(rgb * c.a, c.a);                     // premultiply again
}
GLSL ES 3.0 fragment shader for WebGL2: brightness, contrast, then a curves lookup.

Note the un-premultiply step. Compositing pipelines usually keep colors in premultiplied alpha: color already multiplied by alpha, which makes blending correct and fast. Adjustments must work on the real color, so they divide by alpha first and multiply back after. Forgetting this gives dark fringes around soft edges, a classic bug in hand-written image pipelines.

Render only what changed

The renderer does not run every frame. It runs when something is dirty: each change marks a region, so a slider change dirties the whole layer and a brush dab dirties a small rectangle. The next requestAnimationFrame re-composites only the tiles that intersect dirty rectangles and are visible on screen. Idle, the editor uses no GPU time at all, which matters for laptop batteries.

The second trick is caching the stack. When the user paints on layer 6 of 10, layers 1 to 5 aren't changing. We keep their composite in a texture (the below cache) and layers 7 to 10 in another (the above cache, only valid when they all use the normal blend mode and none of them is an adjustment).

A brush frame then costs one blend of three textures per dirty tile, not ten layers. Any change to a cached layer invalidates its cache. This is a big part of why brushes can hit 60 fps on large documents.

Check your understanding · Exercise 4

The curves slider feels slow on a 24 MP photo. Which changes would help? Select all that apply.

Color precision: banding, bit depth and linear light

A pipeline that is fast can still produce ugly pictures. Two quality problems show up in every serious image editor: banding, where smooth gradients turn into visible stripes, and wrong-looking blends and blurs, where colors mix too dark. Both come from how numbers represent color.

Why 8 bits per channel band

An 8-bit channel has 256 possible values. A blue sky in a photo often uses only a narrow range, say values 100 to 150: 51 distinct steps. Now the user applies levels to stretch that range to the full 0 to 255, so the 51 steps are spread over 256 values and neighbouring pixels jump by 5 instead of 1. On a smooth sky the eye sees each jump as a stripe.

Stack a few adjustments, each rounding to 8 bits in between, and the rounding errors add up. Higher precision in the pipeline can't invent in-between values an 8-bit source never had. But it stops each pass from adding new rounding, and dithering (below) hides the steps that remain.

Banding from rounding between passes. Eight neighbouring sky values, 100 to 107, go through two adjustments: darken by half, then brighten by two. With 8-bit rounding after each pass, the result is 100, 102, 102, 104, 104, 106, 106, 108: neighbours merge into pairs, which shows as stripes. With float intermediates, the result is 100 to 107 again, unchanged.source1001011021031041051061078-bit passes100102102104104106106108float passes100101102103104105106107darken 50%, then brighten 2x
Each 8-bit pass rounds to a whole number, so darkening by half then brightening by two loses every other value. Do the passes in float and round once at the end, and the gradient survives.

Decision Which precision do we store and compute in?

8-bit everywhere

Layers stored as RGBA8. Every intermediate texture is RGBA8 too.

Pros

  • Least memory: 4 bytes per pixel.
  • Works on every GPU with no extensions.

Cons

  • Rounding after each adjustment pass. Stacked adjustments band, especially in skies and skin.

Half float everywhere

Layers and intermediates stored as RGBA16F: 16-bit floating point per channel.

Pros

  • No visible banding. Values may go above 1 and below 0 mid-pipeline without clipping.

Cons

  • Doubles memory: 192 MB per 24 MP layer.
  • Rendering into float textures needs the EXT_color_buffer_float extension in WebGL2. It is widely available on desktop and most recent phones, but missing or buggy on some older mobile GPUs, so we check that the framebuffer is complete.

8-bit storage, half float compositing

Source layers keep RGBA8 tiles (most photos are 8-bit JPEGs anyway).

Compositing and adjustment intermediates use RGBA16F, only for visible tiles.

The final step dithers down to 8 bits for the screen or the export.

Pros

  • Most of the quality for little memory: intermediates exist only for the few dozen visible tiles.
  • 8-bit originals round-trip exactly.

Cons

  • Two texture formats to manage.
  • 16-bit sources (PNG-16, some PSDs) are reduced to 8 bits unless we add a 16-bit layer type later.

What we pick: 8-bit storage with half float compositing, falling back to 8-bit intermediates with dithering when EXT_color_buffer_float is missing. Rounding happens once at the end instead of after every pass, and the memory cost is per visible tile, not per layer.

Dithering adds a tiny amount of noise, less than one 8-bit step, before rounding. Instead of hard stripes, neighbouring pixels round up or down in a fine random pattern, which the eye averages into a smooth gradient. It costs one line in the final shader and hides most remaining banding on screen and in the exported JPEG.

Linear light versus sRGB values

Pixel values in a JPEG are not proportional to light. They are gamma encoded in sRGB: value 128 is about 22% of the light of value 255, not 50%. The encoding spends more of the 256 steps on dark tones, where our eyes are sensitive, which is good for storage and bad for math.

Average a bright red and a bright green pixel by averaging their sRGB numbers and you get a muddy dark yellow, because you averaged encoded numbers, not light. Blurs and image scaling done this way leave dark halos between bright colors.

Mixing pure red, 255, 0, 0, with pure green, 0, 255, 0, half and half. On the left, averaging the sRGB numbers gives 128, 128, 0: a dark olive, too dark. On the right, converting to linear light, averaging, and converting back gives 188, 188, 0: a brighter yellow, which is what mixing real light looks like.average the numbers255, 0, 00, 255, 0128, 128, 0muddy, too darkaverage the light255, 0, 00, 255, 0188, 188, 0brighter, like real light
sRGB numbers are gamma encoded, so their average is darker than the average of the light. Blurs and resizing should mix in linear light; blend modes match Photoshop by using the encoded values.

The fix is to convert to linear light (undo the gamma curve), do the math, and convert back. The right answer differs by operation. For blurs, resizing and anti-aliasing, linear is clearly better, and users notice the difference on high-contrast edges.

For blend modes, users and PSD files expect Photoshop's results, and Photoshop blends RGB in the document's encoded values unless the "Blend RGB Colors Using Gamma" option in its color settings is turned on. So we blend in sRGB values to match, and do blurs and resampling in linear light. The rule is per operation, and it is written down once in the renderer, not decided by each shader author.

Wide-gamut displays are the last piece. Many laptops and phones show Display P3, a color space with more saturated reds and greens than sRGB. A WebGL2 canvas can be told its colors are P3 by setting gl.drawingBufferColorSpace = "display-p3", and a 2D canvas takes { colorSpace: "display-p3" } in getContext; Photoshop on the web draws to a Display P3 canvas.

For v1 we keep the working space sRGB, read embedded ICC profiles on open (converting to sRGB with a warning when they differ), and tag exports as sRGB. The color-picker guide covers color spaces in more depth.

Check your understanding · Exercise 5

A user's sunset sky shows stripes after applying levels, then curves, then hue and saturation. Which fixes address the cause? Select all that apply.

Large images: tiles, mip levels and memory budgets

So far we have assumed every layer fits on the GPU. Let's test that. The naive renderer uploads each layer as one texture the size of the document. A 24 MP photo is 6000 by 4000 pixels, and a 50 MP camera file is about 8700 by 5800.

  • Texture size limits. WebGPU only guarantees textures up to 8192 pixels on a side by default (maxTextureDimension2D); an app can request more if the adapter allows it. WebGL2 reports MAX_TEXTURE_SIZE: the spec only guarantees 2048, most desktop GPUs report 16384, and many phones and older tablets report 4096 or 8192. The 50 MP file doesn't fit as one texture on the default WebGPU limit.
  • GPU memory. Ten 24 MP layers are 960 MB of textures, plus intermediates. Integrated laptop GPUs share system memory, and browsers enforce their own limits. Allocation fails, or the context is lost.
  • Tab memory. The CPU copies of those layers are another 960 MB. Browsers kill tabs that grow too large, sooner on phones and iPads than on desktops. The pdf-viewer guide meets the same wall with canvas memory.
  • Waste. At "fit to screen" on a 2560 by 1440 monitor, the photo covers about 2160 by 1440, roughly 3.1 million screen pixels. We hold 24 million per layer, so about 87% of them are thrown away when sampling.

Decision How do we hold big images?

One full-size texture per layer

Upload each layer whole. Let the GPU sample it at any zoom.

Pros

  • Simplest renderer. No tile seams.

Cons

  • Breaks texture limits on big files.
  • Memory grows with document size times layer count, with no ceiling.

Downscaled proxy for editing, full resolution only on export

Edit a copy scaled to fit, for example 2048 pixels on the long side. Replay all edits at full resolution when exporting.

Pros

  • Small and fast while editing. Works well for adjustment-only editors like Lightroom-style apps.

Cons

  • Zooming to 100% shows a blurry proxy.
  • Brush strokes and selections made on the proxy must be replayed at full size, and they will not match pixel for pixel.

Tiles plus a mip pyramid, loaded on demand

Cut every layer into 512 by 512 tiles. Also keep half, quarter and smaller versions (mip levels), tiled the same way.

Upload only the tiles visible at the current zoom, from the mip level that matches it. Evict the least recently used tiles past a budget.

Pros

  • GPU memory depends on the screen size, not the document size.
  • No single texture is larger than 512 pixels, so limits never bite.
  • Tiles are also the unit for undo diffs, autosave and lazy loading.

Cons

  • Seams: filters like blur need pixels from neighbouring tiles, so tiles need borders or neighbour reads.
  • Bookkeeping for tile ids, mip levels, caches and eviction.

What we pick: Tiles with a mip pyramid. It is how map apps, Photoshop and other big-image tools stay inside fixed memory. The cost is a tile manager, which we'd need anyway for cheap undo and incremental saving.

The mip pyramid of one 24 megapixel layer, every tile 512 by 512 pixels. Level 0 is 6000 by 4000, 12 by 8 tiles, 96 in all; at 100% zoom a 2560 by 1440 screen needs a block of 6 by 4, 24 tiles. Level 1 is 3000 by 2000, 6 by 4 tiles, and at fit zoom all 24 are needed. Level 2 is 1500 by 1000, 3 by 2 tiles. Level 3 is 750 by 500, 2 by 1 tiles.level 06000 x 400096 tiles100%: 24 on GPUlevel 13000 x 200024 tilesfit: all 24level 21500 x 10006 tileslevel 3750 x 5002 tiles
Each level is half the size of the one before, so it has about a quarter of the tiles. The renderer picks the level that matches the zoom, and from fit to 100% the screen needs at most the same 24 tiles, whatever the photo size.

Which tiles are on the GPU

A mip level is the image at half size per step: level 0 is 6000 by 4000, level 1 is 3000 by 2000, level 2 is 1500 by 1000, and so on. All the extra levels together add only about a third to the memory of level 0. For each frame, the renderer picks the smallest level that still has at least one image pixel per screen device pixel, then lists the tiles of that level that intersect the viewport.

ts
const TILE = 512;

function visibleTiles(
  view: { x: number; y: number; w: number; h: number; zoom: number }, // x, y in document pixels
  doc: { w: number; h: number },
) {
  // zoom 0.25 -> level 2 (quarter size). Never sharper than level 0.
  const level = Math.max(0, Math.floor(-Math.log2(view.zoom)));
  const scale = 1 / 2 ** level;                 // level pixels per doc pixel
  const lastCol = Math.ceil((doc.w * scale) / TILE) - 1;
  const lastRow = Math.ceil((doc.h * scale) / TILE) - 1;
  const left = Math.max(0, Math.floor((view.x * scale) / TILE));
  const top = Math.max(0, Math.floor((view.y * scale) / TILE));
  const right = Math.min(lastCol, Math.floor(((view.x + view.w / view.zoom) * scale) / TILE));
  const bottom = Math.min(lastRow, Math.floor(((view.y + view.h / view.zoom) * scale) / TILE));
  const keys: string[] = [];
  for (let row = top; row <= bottom; row++)
    for (let col = left; col <= right; col++) keys.push(`${level}/${col},${row}`);
  return keys; // the tile cache uploads missing ones, newest first
}
Picking a mip level and the visible tiles for one layer. zoom is screen pixels per document pixel.

Worked example on a 2560 by 1440 device-pixel viewport. At fit-to-screen, a 6000 by 4000 photo shows at zoom 0.36, so we use level 1 (3000 by 2000): all 6 by 4 tiles, about 24 MB per layer. At 50% and at 100% zoom, the screen covers 5 by about 3 tiles' worth of pixels, which straddles about 6 by 4 tiles again, because the screen area is the same.

Zoomed in further, a layer costs less: at 400% the screen shows only 640 by 360 document pixels, which touch 2 to 6 tiles depending on alignment. So from fit to 100% a layer costs roughly the screen area in tiles, about 24 MB instead of 96 MB, and never more.

Photo
Zoom
Limit
24 MP photo, 6000 by 4000, 10 layers, zoom 36% on a 2560 by 1440 screen. Whole layers: 960 MB, and the 6000 pixel side fits a 8192 texture limit. Tiled: mip level 1, 6 by 4 tiles, 24 visible per layer, 252 MB on the GPU against a 400 MB budget.level 1: 3000 x 2000, 6 x 4 tilesmarked: tiles on the GPUwhole: 10 x 96 MB= 960 MBtiles: 24 a layer= 252 MB of 400 MB6000 px vs 8192one texture fits
Whole layers cost 960 MB at any zoom. Tiles cost what the screen shows: 24 tiles per layer, 252 MB in total. Switch zooms up to 100%, or switch photo sizes: the count never goes above 24, because it follows the screen, not the photo. At 400% it drops further, since the screen shows fewer document pixels.

Three tiers of memory

The budgets are starting points. We lower them on devices that report little memory through navigator.deviceMemory (Chromium only).
TierHoldsBudget (8 GB laptop)Eviction
GPU texturesVisible tiles of every visible layer, plus the below and above caches.About 400 MBLeast recently used tile goes first. Re-uploading a 1 MB tile from the CPU tier costs on the order of a millisecond.
CPU memoryDecoded tiles recently used or likely next (neighbours of the viewport).About 600 MBLRU. Clean tiles are dropped. Dirty tiles are written to disk first.
Disk (OPFS)Every tile of every layer, compressed.Limited by the storage quotaNever, while the project is open.

The disk tier is what makes a 50 layer project possible. Photoshop on the web faces the same problem: the Adobe and Chrome write-up describes moving data between disk and memory as the user pans, which became fast enough on the web with access handles in the origin private file system (OPFS), a private, per-site file system. We do the same from a worker with createSyncAccessHandle(), which reads and writes a file at byte offsets synchronously, without a promise per call.

Two cheap wins: a tile that is completely transparent, or one solid color, is stored as a flag plus a color, not 1 MB of bytes. A new empty layer is therefore free until someone paints on it. And mip tiles are built lazily from level 0 in a worker or on the GPU, only when a zoom level first needs them.

Check your understanding · Exercise 6

A user opens an 8700 by 5800 photo, adds 8 layers, and zooms to fit on a 1920 by 1080 screen. Which are true with our tiled design? Select all that apply.

Interaction: brushes, pens, selections and live previews

Zoom and pan work as in any canvas app: wheel and pinch zoom around the pointer, space-drag or two fingers to pan, and the view is a transform applied in the final draw. The figma guide walks through that math, so here we focus on what is different in a photo editor: painting pixels with a pen, selecting regions of pixels, and previewing heavy filters.

Getting every point of a pen stroke

A naive brush listens to mousemove and draws a circle at each event. Draw a quick curve and you get a chain of separate dots, or a jagged polygon if you connect them with lines. Two things go wrong. Mouse events carry no pressure, and browsers coalesce fast moves: Chromium, for example, delivers at most one pointermove per frame, so a pen sampling at 240 Hz on a 60 Hz screen produces 4 positions per frame and 3 of them are merged away.

  • Use pointer events for mouse, pen and touch together. event.pressure goes from 0 to 1 on pens (mice report 0.5 while pressed), and pointerType tells you which device it is. tiltX and tiltY are there for brushes that respond to pen angle.
  • Call event.getCoalescedEvents() in the pointermove handler to get every merged sample, each with its own position and pressure. It ships in Chromium and Firefox, and in Safari since 18.2; fall back to the single event when it is missing.
  • Set touch-action: none on the canvas so the browser doesn't scroll or zoom the page while you paint, and call setPointerCapture() on pointerdown so the stroke continues when the pen leaves the canvas.
  • Optionally draw a short prediction from getPredictedEvents() (also in all three engines, Safari since 18.2) to hide a frame of latency, and replace it with real points on the next event.
ts
canvas.addEventListener("pointerdown", (e) => {
  canvas.setPointerCapture(e.pointerId);
  stroke = brush.begin(toDoc(e), e.pressure || 0.5);
  scheduleFrame();
});

canvas.addEventListener("pointermove", (e) => {
  if (!stroke) return;
  const samples = e.getCoalescedEvents?.() ?? [e];
  for (const s of samples) stroke.add(toDoc(s), s.pressure || 0.5);
  scheduleFrame(); // one requestAnimationFrame, however many events
});

const endStroke = () => {
  if (!stroke) return;
  history.push(stroke.commit()); // writes tiles, returns a tile diff
  stroke = null;
};
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", endStroke); // the OS or browser took the pointer away
The input half of the brush. It records samples and schedules a frame; it never touches pixels.

The brush engine: dabs and a stroke buffer

A digital brush stroke is a row of overlapping dabs: stamps of the brush shape, placed along the path. The engine interpolates between samples and places a dab every 10 to 25% of the brush diameter, scaling size or opacity with pressure. A 100 pixel brush moving 400 pixels in one frame needs about 16 to 40 dabs. The GPU draws them as small textured quads in well under a millisecond.

Dabs don't go straight into the layer. They go into a stroke buffer, a temporary texture covering only the stroke's tiles, where overlapping dabs combine with a maximum, not a sum. Then the buffer is composited onto the layer with the stroke's opacity.

Without the buffer, a 50% opacity stroke that crosses itself gets darker where it overlaps, which no painter expects. At pointerup the buffer is merged into the layer's tiles for real, and the tiles it touched are recorded for undo.

Sequence of one brush stroke between the Pen, the Brush tool, the Renderer and the Autosave worker. The pen sends pointerdown, and the tool captures the pointer. Pointermove arrives with four coalesced samples. The tool sends dabs into the stroke buffer on the renderer. On the next frame the ink is visible under the pen. The pen sends pointerup. The tool merges the stroke into tiles and pushes an undo entry. About 2 seconds later the tool hands the new tiles to Autosave, which replies saved once the pointer is updated.PenBrush toolRendererAutosavepointerdownpointermove x4dabs to stroke bufink under the penpointerupmerge into tilesnew tiles (2 s)saved, pointer set
The pixels on screen change in the same frame as the input, with no thread hop. Only the commit and the save, which can take longer, leave the main thread.

Selections are masks

A selection looks like a dashed outline ("marching ants"), but the model is a grayscale mask, exactly like a layer mask: 255 is selected, 0 is not, and values between are partly selected, which gives soft feathered edges. Marquee and lasso start as vector shapes and are rasterized to a mask with anti-aliasing. Every tool that changes pixels multiplies its effect by the selection mask, which is one extra texture read in each shader. The outline is drawn from the mask's edges at screen resolution, so it stays crisp at every zoom.

The magic wand selects connected pixels similar to the one clicked, within a tolerance. That is a flood fill: start at a pixel, visit neighbours, keep those within tolerance, repeat. Flood fill is sequential and branchy, which GPUs handle badly and CPUs handle well.

On a 24 MP layer a fill can visit millions of pixels, which can take a few hundred milliseconds of JavaScript. So it runs in a worker, ideally as WebAssembly, on the full-resolution tiles, while the main thread shows a busy cursor and stays responsive.

Sequence of the magic wand with a failure path, between the User, the Wand tool, the Worker and the Renderer. The user clicks; the tool sends floodFill with id 7 to the worker. 80 milliseconds later the user clicks again; the tool sends cancel 7 and a new fill with id 8. The worker replies that job 7 was cancelled, and the tool drops it. The worker replies with mask tiles for job 8. The tool asks the renderer to show the selection, and the user sees marching ants.UserWand toolWorkerRendererclickfloodFill id 7click againcancel 7, fill id 8id 7: cancelledid 8: mask tilesshow selectionmarching ants
Every job carries an id, and only the latest one may change the selection. Without the id check, a slow reply to the first click could overwrite the second.

The failure path matters. The user clicks the wand, then clicks somewhere else 80 ms later, or changes the tolerance, and the first result must not land on top of the second. Every request carries an increasing job id, and the main thread ignores any reply whose id is not the latest.

The worker also stops early. It checks a cancel flag between rows: an Int32Array over a SharedArrayBuffer when the page is cross-origin isolated, or, without isolation, chunked work that yields so it can read a cancel message. The same pattern guards every worker job in the editor.

Preview at screen resolution, apply at full resolution

Some filters are too slow to run live at full resolution: a 60 pixel radius blur on a 24 MP layer, or a WebAssembly noise reduction filter. The pattern is to preview on what the user sees, the visible tiles at the current mip level, so sliders feel instant.

When the user presses Apply, a worker or the GPU processes all tiles at level 0 with a progress bar and a Cancel button, and mip levels are rebuilt after. For spatial filters like blur, the preview radius is scaled by the mip level. A 60 pixel blur previewed at level 2 is a 15 pixel blur on quarter-size tiles: the same look, on 16 times fewer pixels.

Check your understanding · Exercise 7

Users say fast pen strokes look like connected straight lines, and 50% opacity strokes have dark blotches where they cross. Which fixes are right? Select all that apply.

History: undo for pixels without copying layers

The undo-redo guide covers the general machinery: commands with do and undo, a past and a future stack, grouping, and what happens to redo after a new edit. A photo editor adds one problem that most apps don't have: some edits change millions of pixels, and their inverse can't be computed. You can't "un-blur" a layer or "un-paint" a stroke from the result alone. You need the old pixels.

The naive answer is a full snapshot of the document before each step. With ten 24 MP layers that is 960 MB per step, so fifty steps would be 48 GB. Even snapshotting only the changed layer, 50 brush strokes cost 4.8 GB. Both are impossible in a tab.

Decision How do we undo pixel edits?

Snapshot the changed layer

Before a pixel edit, copy the whole layer. Undo swaps it back.

Pros

  • Trivial to implement and always correct.

Cons

  • 96 MB per step on a 24 MP layer, even for a 10 pixel dab.

Replay from a checkpoint

Store every edit as a command. To undo, restore the last saved checkpoint and replay all commands except the last.

Pros

  • Tiny history: only commands are stored.

Cons

  • Undo time grows with the number of commands since the checkpoint: replaying 200 brush strokes and filters can take seconds.
  • Every operation must be perfectly deterministic, across GPUs too.

Copy-on-write tiles

Tiles are immutable. A pixel edit writes new tiles with new ids and leaves the old ones alone.

A history entry records, per layer, the old and new tile id for each touched tile. Undo puts the old ids back.

Pros

  • Cost is proportional to the area changed: a small stroke over 4 tiles costs 4 MB.
  • Undo and redo are instant: swapping ids, then re-rendering those tiles.
  • Untouched tiles are shared by every history state and by the current document.

Cons

  • Needs tiled storage and reference counting to free tiles that no state uses.
  • Whole-layer filters still touch every tile, so they need a spill to disk.

What we pick: Copy-on-write tiles for pixel edits, plain commands for everything else. Parameter changes (opacity, curves points, layer order, text) store old and new values, a few bytes each. Pixel edits store tile id swaps. Both kinds sit in the same undo stack, so the user sees one consistent history.

Copy-on-write tiles for undo. Before the stroke, a layer points to twelve tiles, a to l. A brush stroke crosses tiles f and g, so the layer after the stroke points to new tiles f2 and g2, and still to the same ten other tiles. The undo entry holds two swaps: f2 back to f, and g2 back to g. Ten tiles are shared, and the cost is 2 MB instead of 96 MB.beforeafter the strokeaabbccddeeff2gg2hhiijjkkllundo entry: 2 swapsf2 to f, g2 to g10 tiles sharedcost: 2 MB, not 96 MB
Old tiles are never changed, only replaced. Undo points the layer back at f and g, and the ten untouched tiles are shared by both states.
ts
type TileSwap = { layerId: string; key: string; before: string | null; after: string | null };

type HistoryEntry =
  | { kind: "pixels"; label: string; swaps: TileSwap[]; bytes: number }
  | { kind: "props"; label: string; layerId: string; before: Partial<Layer>; after: Partial<Layer> }
  | { kind: "structure"; label: string; before: string[]; after: string[] }; // layer order

function undoPixels(e: Extract<HistoryEntry, { kind: "pixels" }>) {
  for (const s of e.swaps) layers.get(s.layerId)!.pixels.tiles.set(s.key, s.before!);
  renderer.markDirty(e.swaps.map((s) => s.key));
}
History entries. Pixel entries hold tile ids, never pixels, so they are small even when the tiles are big.

A memory cap, and where old history goes

History needs both a step limit and a memory limit. Recent versions of Photoshop default to 50 history states; we keep at most 50 steps, and at most about 500 MB in memory of tiles that only history references. Past the memory cap, the oldest history-only tiles are compressed and written to OPFS, and read back only if the user undoes that far.

Past 50 steps, the oldest entries are dropped: their tiles' reference counts go down, and a tile that reaches zero is freed. The same happens earlier if the storage quota runs low.

Worked example. A user paints 25 strokes, each crossing about 4 tiles: 100 MB of old tiles. Then they apply a destructive "sharpen" to the whole 24 MP layer, 96 tiles or 96 MB in one step, and paint 20 more strokes: 80 MB. That is 46 steps and about 280 MB, inside both limits.

If the next action sharpened all ten layers, its old tiles alone would be 960 MB. About 740 MB of history-only tiles move to disk, oldest first, and with 47 steps nothing is dropped yet. The history panel shows the limit honestly, greying out steps that are gone.

Check your understanding · Exercise 8

With copy-on-write tiles, which statements are true? Select all that apply.

Saving, autosave, PSD and export

Three different things get called "saving" in a photo editor, and they need different designs. The project file keeps everything editable: layers, masks, adjustments, text. Autosave protects work in progress against crashes, without the user doing anything. Export flattens the document into one JPEG, PNG, WebP or AVIF for sharing.

The excalidraw guide covers local-first storage in general. Here the challenge is size: a project can be hundreds of megabytes.

Decision What is the native project format?

PSD as the native format

Read and write Photoshop's format directly, with a library such as ag-psd.

Pros

  • Files open in Photoshop and other editors without conversion.

Cons

  • PSD is a complex binary format, and libraries cover parts of it: ag-psd does not support 16 bits per channel or the large PSB format, its text layer support is incomplete, and it does not redraw layer or composite pixels when properties change.
  • The whole file is one blob: saving a one-pixel change rewrites hundreds of megabytes.

Our own format: manifest plus tiles

A JSON manifest describes the layer tree, properties, adjustments and tile ids.

Each tile is stored separately, compressed. A download packs both into one archive file.

Pros

  • Incremental saves: write only the new tiles and a new manifest.
  • Matches our runtime model exactly, so loading is lazy: open the manifest, load visible tiles first.
  • Versioned by us: we can add layer types without waiting for anyone.

Cons

  • Other apps can't open it. We still need PSD import and export as a separate feature.

What we pick: Our own manifest plus tiles format for projects and autosave, with PSD import and export as conversions done in a worker. Many creative tools make this split: a native format shaped like the runtime, and interchange formats at the edges. On PSD export our renderer supplies the flattened composite and each layer's pixels, and we warn when a layer type can't be represented in PSD.

Autosave: incremental, atomic, and off the main thread

Since tiles are immutable, autosave is simple and safe. About 2 seconds after the last change (and at most every 10 seconds during long painting), the save worker writes any tiles not yet on disk to OPFS, then writes the new manifest, then updates a small record in IndexedDB that points to the current manifest. That last write is the commit point.

If the tab dies halfway, the old manifest is still complete and still referenced, so the project opens in its previous state. Old tiles that no manifest or history references are cleaned up later.

Why both stores? OPFS is built for large binary files, and the synchronous access handle in a worker is the fastest way to write them. IndexedDB is good for small structured records, the project list and the commit pointer, and it has transactions. The indexeddb and client-storage lessons cover both.

We call navigator.storage.persist() after the first project so the browser is less likely to evict our data under storage pressure, and navigator.storage.estimate() to warn before the quota runs out.

The autosave state machine. Saved goes to Dirty on an edit. Dirty starts a timer and goes to Waiting, a 2 second debounce. After 2 quiet seconds, Waiting goes to Writing, which writes new tiles to OPFS. When done, Writing goes to Committing, which sets the IndexedDB pointer, then back to Saved. If a write fails, Writing goes to Failed, which retries with backoff back to Writing. If the error is a full quota, Failed goes to Storage full, which asks the user.SavedDirtyWaiting2 s debounceWritingnew tiles, OPFSCommittingIDB pointerFailedretry, backoffStorage fullask the useredittimer2 s quietdonesavederrorretryquota
More edits while Waiting restart the timer, though long painting still saves at least every 10 seconds. Only the Committing step makes a save visible, so a crash in any other state leaves the last complete save in place.

Cloud save, when the user signs in, uploads the same tiles and manifest. Because tiles are content-addressed (the id is a hash of the bytes), the server can answer "which of these 96 tile ids do you already have?" and the client uploads only the missing ones. A second save after painting one stroke uploads about 4 compressed tiles, a megabyte or two, not 400 MB.

Export: flatten, encode, estimate

Export runs the same renderer at full resolution, tile by tile, into a flat image, then encodes it. Both steps run in a worker so the UI keeps working. The obvious encoder is the browser's own: OffscreenCanvas.convertToBlob({ type, quality }) in a worker, or canvas.toBlob on the main thread. Its support is uneven, so we check what we got.

FormatBrowser encoderOur plan
JPEGEverywhere, with a quality from 0 to 1.Use the browser. Default quality 0.85.
PNGEverywhere. Lossless, no quality setting.Use the browser. Warn when the file will be large.
WebPChromium and Firefox. Safari can't encode WebP and returns a PNG instead.Check blob.type. If it isn't image/webp, use a WebAssembly encoder.
AVIFNo major browser encodes AVIF from a canvas; asking for it returns a PNG.WebAssembly encoder (libavif compiled to Wasm, as Squoosh does), loaded only when chosen.

Canvas size limits bite at export. Mobile Safari has long capped canvas area at 16,777,216 pixels (4096 by 4096), so a single 24 MP canvas may fail to allocate on an iPad. The WebAssembly encoders avoid this: we feed them rows of pixels from our tiles, and no canvas holds the whole image. The encoders are a few hundred KB to over 1 MB of Wasm each, so they are loaded with a dynamic import() only when the user picks that format (see the code-splitting lesson).

Users want to see the file size before they export. Encoding 24 MP for every slider move is too slow, so we estimate: encode a 1 MP sample (a downscaled version plus a full-resolution crop from the busiest area, for texture), then scale the byte count by the pixel ratio. The dialog labels it as an estimate, refreshes it a moment after the slider stops, and shows the exact size after the real export.

The image-optimization lesson explains how quality settings trade size for artifacts. Like the image-cropper guide, we drop EXIF location data by default and say so in the dialog.

Check your understanding · Exercise 9

A user paints one stroke on a 400 MB project and the tab crashes 3 seconds later. Which properties of our design limit the damage? Select all that apply.

Architecture, data model and interfaces

With the hard parts decided, we can draw the boxes. The main rule: React and the DOM own the panels, and never the pixels. A slider in the adjustments panel dispatches a command; it does not re-render the canvas through React.

The canvas is drawn by a render engine that reads the document directly, on its own requestAnimationFrame schedule. Mixing the two, for example storing the brush stroke in React state, would put a full reconciliation pass on the path of every pointer event. The UI framework itself matters less: Photoshop on the web builds its panels from Lit-based web components (Adobe's Spectrum Web Components), and the same split applies.

The photo editor's architecture. On the main thread: the UI shell in React with panels and dialogs, and Tool controllers for brush, wand and move. Both send commands to the Command bus with history, which applies them to the Document store holding the layer tree; the UI subscribes to the store. The Render engine, WebGL2 with a tile cache, reads the document and gets tiles from the Tile store, which tracks ids and reference counts, and draws the Canvas. Tool controllers send jobs to the Worker pool for decode, wand and export. The Tile store hands new tiles to the Save worker, which writes to OPFS and IndexedDB and uploads to the Project API.Main threadUI shellReact panelsToolsbrush, wand, moveCommand bus+ historyDocumentlayer treeRender engineWebGL2, cacheTile storeids, refcountsCanvason screenReact neverdraws pixelsWorkersWorker pooldecode, wand, exportSave workerautosave, uploadStorageOPFS + IDBtiles, manifestProject APIcloud save
React renders the panels and never the pixels. The render engine reads the document directly, and every heavy or slow job leaves the main thread for a worker.
PartRuns onOwnsTalks to
UI shellMain thread, ReactToolbar, layers panel, adjustment panels, dialogs, shortcuts.Subscribes to the document store; sends commands to the command bus.
Command bus and historyMain threadEvery change as a command; the undo and redo stacks.Applies commands to the document store; pushes history entries.
Document storeMain threadThe layer tree, properties, selection, active tool, view transform.Notifies the UI and the render engine of changes.
Render engineMain thread, WebGL2Shaders, the GPU tile cache, below and above caches, dirty regions.Reads the document and the tile store; draws the canvas.
Tool controllersMain threadPointer handling for brush, eraser, marquee, lasso, wand, move.Draw previews through the render engine; emit commands on commit.
Tile storeMain thread index, worker IOTile ids, reference counts, the CPU tile cache, disk paging.The save worker for OPFS; the render engine for uploads.
Worker pool2 to 4 workers, WasmDecode and tiling, mip building, flood fill, heavy filters, PSD, export encoders.Receives jobs with transferable buffers; returns tiles or masks.
Save worker1 workerAutosave, OPFS sync handles, IndexedDB pointer, cloud upload.The tile store and the project API.

Why are the boundaries here? The render engine stays on the main thread, next to the input handlers, because the brush path must be short: pointer event, dabs into the stroke buffer, composite, all in one frame with no thread hop. Rendering WebGL in a worker through OffscreenCanvas is possible and keeps the UI smooth during heavy frames, but it adds one message of latency to every brush move.

So we keep rendering on the main thread, protect it with a strict per-frame budget (see the performance chapter), and use workers for jobs that take longer than a frame anyway.

The tile store sits between everyone because tiles are shared: the renderer reads them, history holds old ids, the save worker writes them, and workers produce new ones. One owner with reference counts is how we avoid both leaks and use-after-free bugs, where a tile is evicted while a history entry still needs it.

Data flow through the editor. Opening: the File, a JPEG or PSD blob, goes to the Decode worker, which transfers 1 MB tiles to the Tile store. Rendering: the Tile store uploads visible tiles to the Render engine, the Document sends dirty regions to it, and it draws a frame on the Canvas. Editing: the user sees the canvas and acts with Tools, which send events to the Command bus, which applies changes to the Document. Saving: the Document hands the manifest and new tiles to the Save worker, which writes them to OPFS and the cloud API.FileJPEG, PSD blobDecode workerWasmTile storeCPU tierblobtilesCanvason screenRender engineWebGL2Documentlayer treevisible tilesdirtyframeToolspointer, slidersCommand buscommandsSave workerto OPFS, APIuser actseventsapplynew
Bytes travel in one direction on each path: file to tiles, tiles to GPU, commands to document, new tiles to disk. Only tile ids and small commands move between the parts that decide things.

The data flow diagram follows a photo from the file picker to the screen and back to disk. On open, the decode worker sends the file's small embedded preview first, then the low mip levels, then level 0 tiles, so a blurry full image appears within a few hundred milliseconds and sharpens as level 0 arrives.

On edit, a command changes the document, the render engine re-composites dirty tiles, and the save worker picks up new tiles a couple of seconds later. On export, the worker pool reads tiles, flattens and encodes, and hands back a Blob for download.

Check your understanding · Exercise 10

Which responsibilities are in the right place? Select all that apply.

Data model: what lives where

We already have the layer types. Now we place every piece of client state, because a photo editor has unusually many kinds: some are tiny and change every frame, some are huge and change rarely, and putting one in the wrong place either leaks memory or makes the app slow.

EntitySizeLives inSaved?In history?
Layer tree and propertiesA few KBDocument store (memory)Yes, in the manifestYes
Tiles1 MB each raw, about 0.2 to 0.6 MB compressedTile store: GPU, CPU and OPFS tiersYes, as files by idYes, by id
Selection maskTiled, 1 byte per pixelDocument store plus tile storeYesYes
View: zoom, pan, rotationBytesEditor state, outside historyPer project, as a preferenceNo
Active tool and brush settingsBytesEditor state; brush presets in localStoragePer userNo
In-progress strokeOne stroke buffer textureTool controller and GPUNoOnly once committed
Project list and commit pointerKBIndexedDBYesNo
Cloud project metadataKBServer, fetched with a normal data cacheServer sideNo

Two rules come out of this table. First, view state never goes into history: undo should not jump the zoom around, and people pan constantly. Second, anything with pixels is referenced by id from small records, never embedded. That keeps the document store small enough to diff, serialize and send to the UI cheaply, while the big bytes move through their own, carefully budgeted path.

Where state lives. In main memory: the Layer tree, a few KB; History, holding tile id swaps; and View and tool state, not in history. The layer tree points by tile id to the Tile store's tiers: the GPU tier, about 400 MB; the CPU tier, about 600 MB; and the OPFS tier with all tiles. History points to old ids in the CPU tier. Saved state: IndexedDB with the projects and the commit pointer, which points to the manifest in OPFS; localStorage with brush presets; and the Server with the cloud copy. A note says pixels are referenced by id, never embedded.Main memoryLayer treefew KBHistorytile id swapsView, toolnot in historyTile storeGPU tierabout 400 MBCPU tierabout 600 MBOPFS tierall tilesSavedIndexedDBprojects, pointerlocalStoragebrush presetsServercloud copypixels are referenced by id, never embedded
Small records point at big bytes. The layer tree and history only hold tile ids, so they stay a few KB and are cheap to save, diff and undo.
json
{
  "format": "cfpe",
  "version": 3,
  "width": 6000,
  "height": 4000,
  "colorSpace": "srgb",
  "tileSize": 512,
  "layers": [
    { "id": "L1", "kind": "raster", "name": "Photo", "opacity": 1, "blendMode": "normal",
      "visible": true, "tiles": { "0,0": "b3f9e1", "1,0": "77ac02" } },
    { "id": "L2", "kind": "adjustment", "name": "Curves", "opacity": 1, "blendMode": "normal",
      "visible": true, "adjustment": { "op": "curves", "points": [[0, 0], [0.25, 0.2], [0.75, 0.82], [1, 1]] } },
    { "id": "L3", "kind": "text", "name": "Title", "opacity": 0.9, "blendMode": "screen",
      "visible": true, "text": "Lisbon", "font": "Inter", "sizePx": 220, "color": "#ffffff" }
  ],
  "solidTiles": { "e0": [0, 0, 0, 0] }
}
A project manifest. Tile ids are content hashes, so identical tiles are stored once.

The version field matters more than it looks. The format will change: new layer kinds, new adjustment parameters. Every release must open every older version, so the loader runs small migration functions from version 1 to 2, 2 to 3, and so on, before building the document. Newer files opened by an older cached version of the app (a stale service worker, for example) should fail with a clear "please reload to update" message, not a half-loaded document.

Think first

Should mip level tiles be saved in the project file?

Check your understanding · Exercise 11

Which pieces of state should not be undoable? Select all that apply.

Interfaces: commands, worker jobs and the save API

A photo editor has fewer network calls than a feed, but more internal contracts. Three matter in an interview: the command API that every UI part uses to change the document, the message format between the main thread and workers, and the small server API for cloud save.

Commands

ts
type Command =
  | { type: "layer.add"; layer: Layer; index: number }
  | { type: "layer.move"; id: string; toIndex: number; parentId?: string }
  | { type: "layer.set"; id: string; props: Partial<Pick<Layer, "opacity" | "blendMode" | "visible" | "name">> }
  | { type: "adjustment.set"; id: string; adjustment: Adjustment }
  | { type: "pixels.commit"; layerId: string; swaps: TileSwap[]; label: string }
  | { type: "selection.set"; maskTiles: Record<string, string> | null };

interface Editor {
  dispatch(cmd: Command): void;
  beginTransaction(label: string): void;  // slider pointerdown
  endTransaction(): void;                  // slider pointerup: one history entry
  subscribe(listener: (doc: DocumentState) => void): () => void;
}
Every change goes through dispatch. Sliders use begin and end so a drag becomes one undo step.

The transaction pair is the detail interviewers like. Dragging the curves point produces 60 adjustment.set commands per second. Each one renders, but only endTransaction creates a history entry, holding the value before the drag and the value after. Without it, undo would step back through hundreds of tiny slider positions.

Worker jobs

ts
type Job =
  | { id: number; kind: "decode"; file: File; tileSize: 512 }
  | { id: number; kind: "floodFill"; tiles: ArrayBuffer[]; at: [number, number]; tolerance: number; contiguous: boolean }
  | { id: number; kind: "filter"; op: "unsharp" | "denoise"; tiles: ArrayBuffer[]; params: Record<string, number> }
  | { id: number; kind: "export"; manifestId: string; format: "jpeg" | "png" | "webp" | "avif"; quality: number } // reads tiles from OPFS
  | { id: number; kind: "cancel"; target: number };

type JobResult =
  | { id: number; ok: true; tiles?: ArrayBuffer[]; blob?: Blob; progress?: number }
  | { id: number; ok: false; error: "cancelled" | "out-of-memory" | "unsupported" | "failed"; message?: string };

worker.postMessage(job, "tiles" in job ? job.tiles : []); // transfer, don't copy
Jobs and results. Pixel buffers go in the transfer list, so they move between threads without a copy.

Transferring detaches a buffer from the sender, so the tile store never transfers a tile it still serves. It sends a copy, or the worker reads the tile from OPFS itself; results such as new tiles, masks and blobs come back by transfer, with no copy.

Errors are typed because each one has a different reaction. cancelled is silent, and out-of-memory lowers the cache budgets and retries once with smaller batches. unsupported (a HEIC file in a browser that can't decode it, say) shows a clear message with a suggestion. Progress results stream for long jobs, so export and full-resolution filters show a real progress bar.

Cloud save API

http
POST /api/projects/p_81/tiles/missing
{ "ids": ["b3f9e1", "77ac02", "c41d09"] }
200 { "missing": ["c41d09"] }

PUT /api/projects/p_81/tiles/c41d09      (body: compressed tile bytes)
201

PUT /api/projects/p_81/manifest
If-Match: "v41"
{ "version": 3, "layers": [ ... ] }
200 ETag: "v42"
412 Precondition Failed  -> another device saved first
Upload only missing tiles, then commit the manifest with a version check.

The If-Match header turns the manifest commit into a compare-and-swap: it succeeds only if nobody else saved since we last loaded. Without live collaboration, a conflict is rare (two tabs, or a laptop and an iPad), and the honest answer is to keep both: save ours as a copy and tell the user. Tiles never conflict because they are content-addressed.

Check your understanding · Exercise 12

A user drags the opacity slider from 100% to 40% and then presses undo once. What should happen? Pick one.

Performance and accessibility

Performance in a photo editor is three separate problems with three separate budgets. Painting must hold the frame rate, and opening a big file must show something fast. The app itself, often several megabytes of JavaScript and WebAssembly, must load quickly on the second visit. The runtime-performance lesson covers the general tools; here are the numbers for this app.

The brush frame budget

One 16.7 millisecond frame while painting a 24 megapixel document. Reading coalesced input takes 0.5 ms, dabs on the GPU 1 ms, compositing the dirty tiles 3 ms, and drawing the canvas 1 ms: 5.5 ms of our work against an 8 ms target. The browser's own work takes about 3 ms, and about 8 ms stay free.input, coalesced0.5 msdabs on the GPU1 mscomposite tiles3 msdraw the canvas1 msbrowser work3 msfree8.2 msours: 5.5 ms of an 8 ms target
Our part stays under 8 ms, so a slower laptop or a heavy frame still has room. The big item is compositing, which the below and above caches keep small.

At 60 Hz a frame is 16.7 ms, and the browser needs a few milliseconds of it for its own work. Our target is to finish our part in about 8 ms, which leaves headroom for slower machines and for spikes.

The budget for one brush frame on a 24 MP document: reading coalesced events and interpolating, under 0.5 ms; drawing 20 to 40 dabs into the stroke buffer, about 1 ms of GPU time; compositing the dirty tiles with the below and above caches, 2 to 4 ms; drawing the visible canvas and the cursor, about 1 ms. React updates for panels are not in the brush frame at all: the layers panel thumbnail updates after the stroke ends.

  • Never allocate per frame. Reuse typed arrays and textures; garbage collection pauses of 10 ms or more show up as stutter in the stroke.
  • No GPU readbacks during a stroke. readPixels waits for the GPU to finish everything queued. Histograms update after the stroke, from a small mip level.
  • Big strokes split work. A 2000 pixel brush dirties many tiles at once. If compositing them would blow the budget, composite the tiles nearest the pen first and the rest on the next frame.
  • Measure in the field. The Long Animation Frames API (PerformanceObserver with type long-animation-frame, in Chromium) reports frames over 50 ms with the scripts that caused them. We log those during strokes, along with the tool and document size.

First paint of a huge file

Decoding a 24 MP JPEG takes roughly 100 to 300 ms on a laptop, and much longer for a 50 MP file or on a tablet. Then we cut tiles and build mips. Waiting for all of that before showing anything feels broken.

The progressive plan is to first show whatever small image the file already carries. Many camera JPEGs embed a small EXIF thumbnail, and PSD files usually contain a flattened composite of the whole document, which a library like ag-psd can read without decoding every layer. That appears within a few hundred milliseconds, scaled up and blurry.

Then the decode worker produces the low mip levels, then level 0 tiles nearest the viewport, and the image sharpens. Editing tools unlock when the active layer's level 0 tiles are ready.

Loading the editor itself

A full editor is a large app. Photoshop on the web ships large WebAssembly modules and precaches its JavaScript and WebAssembly with a Workbox service worker. Together with V8's script caching, Adobe estimates this cut the time spent on code initialization by 75%.

We do the same: precache the core (shell, render engine, brush) in the service worker, and split everything else with dynamic import(): the PSD library when a PSD is opened, the AVIF encoder when AVIF is chosen, the denoise filter when its menu item is clicked. The code-splitting lesson explains how to pick split points. Our target is the editor ready to paint in under 2 seconds on a repeat visit.

BudgetTargetHow we keep it
Brush frame8 ms of our work, 60 fpsStroke buffer, below and above caches, no allocations, no readbacks
Adjustment sliderVisible result each frameShaders on visible tiles at screen mip level
Open 24 MP JPEGPreview under 300 ms, sharp under 3 sEmbedded preview, progressive mips, decode in a worker
Repeat app loadReady to paint under 2 sService worker precache, lazy tools and encoders
MemoryUnder about 1.5 GB totalTile tiers with LRU eviction, history byte cap

Crashes from memory are the failure users hate most, and they leave no error to catch. We set a "session open" flag in IndexedDB on start, clear it on pagehide, and set it again on pageshow when the page comes back from the back/forward cache. If the next launch finds it still set, the last session probably crashed: we report it with the document size and layer count, offer to restore from autosave, and start with smaller cache budgets. The memory-leaks lesson covers finding the leaks behind such crashes.

Check your understanding · Exercise 13

Brush strokes stutter every second or so on big documents, but average frame time is fine. Which causes fit? Select all that apply.

Accessibility: what a canvas app can and can't promise

A canvas is one element with pixels in it. A screen reader sees nothing inside it, and a keyboard can't point at a pixel. So we start with honesty: painting a mask by hand around someone's hair is visual, fine-motor work, and we won't pretend otherwise.

But most of what people do in a photo editor is not freehand painting. Opening, adjusting exposure and color, cropping, adding text, managing layers, and exporting can all be fully operable with a keyboard and a screen reader. That is the promise we make and test.

  • Sliders are real sliders. Each adjustment uses a native input type="range" with a visible label and a number field next to it for exact values. aria-valuetext speaks meaning, like "Brightness plus 20" or "Hue minus 15 degrees", not just a number. Arrow keys move by one step, and Home and End jump to the ends.
  • The layers panel is a list with keyboard reordering. Arrow keys move focus between layers, Space toggles visibility, and Alt with an arrow moves the layer up or down. Each move is announced in a polite live region: "Curves moved above Photo, position 2 of 5". Drag and drop is not the only way to reorder.
  • The canvas describes the document. It gets role="img" and a label kept up to date: "Lisbon.jpg, 6000 by 4000 pixels, 4 layers, zoom 50%". Zoom and pan work from the keyboard (Ctrl with plus and minus, arrows to pan), and selections can be typed as numbers in a dialog: x, y, width and height.
  • Shortcuts are safe. Single letter tool shortcuts (B for brush, E for eraser) can be turned off or remapped, as WCAG 2.1 success criterion 2.1.4 (Character Key Shortcuts) requires, because speech input users can trigger them by accident. A shortcuts dialog lists every command, and a command palette makes each one searchable.
  • Focus is managed. Dialogs (export, levels) trap focus and return it to the control that opened them. After a layer is deleted, focus moves to the next layer, not to the top of the page.
  • Motion and color. Animated zoom transitions respect prefers-reduced-motion. The selection outline uses black and white dashes so it shows on any photo, and the UI never relies on color alone to show the active layer or tool.

There are useful middle grounds for visual tasks. Automatic tools reduce the need for fine pointer control: "select subject" or "auto levels" are one command, so they are as accessible as a button. Numeric transforms (rotate by 3 degrees, move by 10 pixels) replace dragging handles. The keyboard-navigation lesson covers the roving focus pattern used in the layers panel and toolbar.

Think first

How would you test that a screen reader user can brighten a photo and export it?

Running it in the interview

This question is wide enough to fill three hours. In 45 to 60 minutes you must pick what to go deep on, and say out loud what you are leaving out. Interviewers asking it usually want to see two things: that you know pixels are expensive (memory, GPU, workers), and that you separate the document model from the pixels it produces (non-destructive editing, history).

MinutesWhat to doWhat to draw or say
0 to 5Scope questions.Layers or quick filters? Photo sizes? Pen input? Collaboration? Write the numbers: 24 MP, 10 layers, 96 MB per layer.
5 to 15Document model.The layer tree with raster, adjustment and group layers. Say "originals plus parameters" and why.
15 to 25Rendering.Bottom-up compositing in WebGL shaders; workers and Wasm for flood fill and encoding. Mention 8-bit banding.
25 to 35Large images.Tiles, mip levels, visible tiles only, the three memory tiers. Do the memory math out loud.
35 to 45History and saving.Copy-on-write tiles for undo, a byte cap, incremental autosave to OPFS with an atomic pointer.
45 to 55Architecture and one deep dive the interviewer picks.The component diagram, then brush latency, export, or accessibility.
55 to 60Wrap up.Trade-offs you made, and what you'd build next: WebGPU, 16-bit layers, collaboration.

Draw the layer tree first, not the component diagram. It is the thing every later answer refers to: the renderer walks it, history stores changes to it, the save format serializes it. Then draw the tile grid with a viewport rectangle over it, and say how many tiles are inside. Those two pictures carry most of the interview.

Follow-up questions

Think first

Would you use WebGPU instead of WebGL2?

Think first

How would you add real-time collaboration later?

Think first

The app crashes on an iPad with a 40 MP photo and 12 layers. What do you change?

Check your understanding · Exercise 14

You have 10 minutes left and haven't discussed history. Which summary is strongest? Pick one.

Summary

  • Pixels are expensive: a 24 MP layer is 96 MB raw, so do the memory math first and let it drive the design.
  • Model the document as a tree of layers. Keep original pixels in raster layers and everything else (adjustments, masks, opacity, blend modes, text) as parameters rendered live.
  • Composite and adjust on the GPU with WebGL2 shaders; use workers with WebAssembly for flood fill, PSD parsing and export encoding. React owns the panels, never the pixels.
  • Store 8-bit tiles but composite in half float and dither once at the end to avoid banding. Blur and resample in linear light.
  • Tile everything at 512 by 512 with mip levels, upload only visible tiles, and keep three memory tiers: GPU, CPU and OPFS, each with a budget and LRU eviction.
  • Make tiles immutable. Undo becomes tile id swaps, autosave writes only new tiles plus an atomic pointer, and cloud save uploads only missing tiles.
  • Read every pen sample with getCoalescedEvents(), paint dabs into a stroke buffer, and keep the brush frame under about 8 ms.
  • Be honest about accessibility: freehand painting is visual, but adjusting, layers, text and export must work fully with a keyboard and a screen reader.

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