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
| Question | Answer we assume | What 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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
| Quantity | Value | Why it matters |
|---|---|---|
| One 24 MP RGBA layer, 8 bits per channel | 96 MB | The unit of memory cost for everything else. |
| Same layer at 16 bits per channel | 192 MB | Better quality costs double. |
| Same layer at 32-bit float per channel | 384 MB | Rarely worth it for every layer. |
| Ten 8-bit layers | 960 MB | Already near the budget before any undo history. |
| One 512 by 512 RGBA tile | 1 MB | The unit we load, upload, evict and diff. |
| Tiles in one 24 MP layer | 12 by 8 = 96 tiles | Only the visible ones need to be on the GPU. |
| Frame budget at 60 Hz | 16.7 ms | Compositing, 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?
It works for a demo and breaks fast. Browsers cap canvas sizes and total canvas memory, so ten 24 MP canvases may not even allocate, especially on iPad. CSS filters only cover a few adjustments: there is no curves or levels, and they run at screen resolution, so what you see is not what you export.
Most of all, export and the screen would use two different pipelines: CSS for the screen and something else for the file. They will drift, and users will see colors in the export that they never saw on screen. One renderer for both is the safer design.
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?
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
| Kind | What it stores | How it renders |
|---|---|---|
| Raster | Tiled pixels, plus bounds in document space. | Sample the tiles, apply the transform. |
| Adjustment | An operation and its parameters, like curves with 5 control points. | Takes everything composited below it in its group and runs a shader on it. |
| Text | The string, font, size, color, box. | Rasterized into a cached texture, re-rasterized when edited or zoomed. |
| Shape | A vector path, fill and stroke. | Rasterized at the current zoom, so edges stay crisp. |
| Group | Children, 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 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.
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
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.
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?
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.
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.
#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
}
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.
Decision Which precision do we store and compute in?
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.
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 reportsMAX_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?
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.
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.
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
}
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.
Three tiers of memory
| Tier | Holds | Budget (8 GB laptop) | Eviction |
|---|---|---|---|
| GPU textures | Visible tiles of every visible layer, plus the below and above caches. | About 400 MB | Least recently used tile goes first. Re-uploading a 1 MB tile from the CPU tier costs on the order of a millisecond. |
| CPU memory | Decoded tiles recently used or likely next (neighbours of the viewport). | About 600 MB | LRU. Clean tiles are dropped. Dirty tiles are written to disk first. |
| Disk (OPFS) | Every tile of every layer, compressed. | Limited by the storage quota | Never, 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.pressuregoes from 0 to 1 on pens (mice report 0.5 while pressed), andpointerTypetells you which device it is.tiltXandtiltYare there for brushes that respond to pen angle. - Call
event.getCoalescedEvents()in thepointermovehandler 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: noneon the canvas so the browser doesn't scroll or zoom the page while you paint, and callsetPointerCapture()onpointerdownso 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.
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 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.
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.
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?
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.
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));
}
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?
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.
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.
| Format | Browser encoder | Our plan |
|---|---|---|
| JPEG | Everywhere, with a quality from 0 to 1. | Use the browser. Default quality 0.85. |
| PNG | Everywhere. Lossless, no quality setting. | Use the browser. Warn when the file will be large. |
| WebP | Chromium 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. |
| AVIF | No 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.
| Part | Runs on | Owns | Talks to |
|---|---|---|---|
| UI shell | Main thread, React | Toolbar, layers panel, adjustment panels, dialogs, shortcuts. | Subscribes to the document store; sends commands to the command bus. |
| Command bus and history | Main thread | Every change as a command; the undo and redo stacks. | Applies commands to the document store; pushes history entries. |
| Document store | Main thread | The layer tree, properties, selection, active tool, view transform. | Notifies the UI and the render engine of changes. |
| Render engine | Main thread, WebGL2 | Shaders, the GPU tile cache, below and above caches, dirty regions. | Reads the document and the tile store; draws the canvas. |
| Tool controllers | Main thread | Pointer handling for brush, eraser, marquee, lasso, wand, move. | Draw previews through the render engine; emit commands on commit. |
| Tile store | Main thread index, worker IO | Tile ids, reference counts, the CPU tile cache, disk paging. | The save worker for OPFS; the render engine for uploads. |
| Worker pool | 2 to 4 workers, Wasm | Decode and tiling, mip building, flood fill, heavy filters, PSD, export encoders. | Receives jobs with transferable buffers; returns tiles or masks. |
| Save worker | 1 worker | Autosave, 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.
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.
| Entity | Size | Lives in | Saved? | In history? |
|---|---|---|---|---|
| Layer tree and properties | A few KB | Document store (memory) | Yes, in the manifest | Yes |
| Tiles | 1 MB each raw, about 0.2 to 0.6 MB compressed | Tile store: GPU, CPU and OPFS tiers | Yes, as files by id | Yes, by id |
| Selection mask | Tiled, 1 byte per pixel | Document store plus tile store | Yes | Yes |
| View: zoom, pan, rotation | Bytes | Editor state, outside history | Per project, as a preference | No |
| Active tool and brush settings | Bytes | Editor state; brush presets in localStorage | Per user | No |
| In-progress stroke | One stroke buffer texture | Tool controller and GPU | No | Only once committed |
| Project list and commit pointer | KB | IndexedDB | Yes | No |
| Cloud project metadata | KB | Server, fetched with a normal data cache | Server side | No |
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.
{
"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] }
}
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?
Mostly no: they can always be rebuilt from level 0, and they add about a third to the size. But rebuilding them on open delays the first sharp frame for a big project.
A good middle is to save one small level, such as the level that fits in about 1024 pixels, as a preview. It opens instantly, doubles as the thumbnail in the project list, and costs a few hundred KB. The rest are built in a worker after open.
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
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;
}
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
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
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
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
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
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.
readPixelswaits 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 (
PerformanceObserverwith typelong-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.
| Budget | Target | How we keep it |
|---|---|---|
| Brush frame | 8 ms of our work, 60 fps | Stroke buffer, below and above caches, no allocations, no readbacks |
| Adjustment slider | Visible result each frame | Shaders on visible tiles at screen mip level |
| Open 24 MP JPEG | Preview under 300 ms, sharp under 3 s | Embedded preview, progressive mips, decode in a worker |
| Repeat app load | Ready to paint under 2 s | Service worker precache, lazy tools and encoders |
| Memory | Under about 1.5 GB total | Tile 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-valuetextspeaks 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
Altwith 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 (Ctrlwith 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?
Write the task as a script and run it with the screen reader on and the mouse unplugged: open a file with the file picker, move to the adjustments panel, add brightness, set it to plus 30 with the arrow keys and hear the value, open export, choose JPEG, hear the size estimate, and save.
Automated checks with axe catch missing labels and roles, but only the scripted run catches problems like a live region that is too noisy, or a dialog that loses focus after export. Run it before every release that touches panels.
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).
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope questions. | Layers or quick filters? Photo sizes? Pen input? Collaboration? Write the numbers: 24 MP, 10 layers, 96 MB per layer. |
| 5 to 15 | Document model. | The layer tree with raster, adjustment and group layers. Say "originals plus parameters" and why. |
| 15 to 25 | Rendering. | Bottom-up compositing in WebGL shaders; workers and Wasm for flood fill and encoding. Mention 8-bit banding. |
| 25 to 35 | Large images. | Tiles, mip levels, visible tiles only, the three memory tiers. Do the memory math out loud. |
| 35 to 45 | History and saving. | Copy-on-write tiles for undo, a byte cap, incremental autosave to OPFS with an atomic pointer. |
| 45 to 55 | Architecture and one deep dive the interviewer picks. | The component diagram, then brush latency, export, or accessibility. |
| 55 to 60 | Wrap 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?
Behind the same renderer interface, yes, where it is available: compute shaders make histograms, flood fill on the GPU, and some filters much easier, and its error handling is clearer. But WebGL2 still has the widest reach, and the shaders are similar enough that supporting both is mostly a translation layer.
I'd ship WebGL2 first, add WebGPU behind a feature check, and compare frame times and crash rates before switching the default.
Think first
How would you add real-time collaboration later?
The document model helps: parameter edits are small commands that can be sent and merged like any other operation, as in the figma guide. Pixel edits are harder. Two people painting on the same layer produce tile swaps that conflict.
A practical rule is to lock a layer while someone paints on it, sync tiles by content id (only new tiles travel), and let everything else merge per property, last writer wins. That covers most real use without inventing pixel-level merging.
Think first
The app crashes on an iPad with a 40 MP photo and 12 layers. What do you change?
First, measure: which tier is too big? Then lower the GPU and CPU tile budgets on devices that report little memory, and make sure eviction actually frees ImageBitmaps and textures. Spill more history to OPFS earlier.
If that is not enough, cap the working resolution on that device class with a clear message, for example offering to downscale to 24 MP on open. An honest limit beats a crash that loses work.
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.
References
- Photoshop's journey to the web (web.dev) How Adobe ported Photoshop with WebAssembly and Emscripten, OPFS for paging, a Display P3 canvas, Lit web components and Workbox.
- PointerEvent: getCoalescedEvents() (MDN) Every pointer sample between frames, for smooth strokes.
- Origin private file system (MDN) Fast file storage, including synchronous access handles in workers.
- OffscreenCanvas (MDN) Canvas and WebGL in workers, and convertToBlob for encoding.
- GPUSupportedLimits (MDN) WebGPU limits such as maxTextureDimension2D.
- WebGPU implementation status (gpuweb wiki) Which browsers and platforms ship WebGPU, and since when.
- EXT_color_buffer_float (MDN) The WebGL2 extension that makes RGBA16F and other float formats renderable.
- WebGL best practices (MDN) Texture memory, readback stalls, context loss and more.
- HTMLCanvasElement: toBlob() (MDN) Browser encoding, the PNG fallback for unsupported types, and per-format support.
- Understanding SC 2.1.4: Character Key Shortcuts (W3C) Why single-key shortcuts must be possible to turn off or remap.
- ag-psd A JavaScript library for reading and writing PSD files, with its limitations listed.
- Squoosh Image codecs compiled to WebAssembly, running in the browser.
Concepts behind it
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.