Web Platform · Rendering Performance

How a Frame Is Produced: Style, Layout, Paint, and Composite

The stages a browser runs between a DOM change and a changed pixel, which stages each kind of change can skip, and where the frame budget is actually spent.

A browser does not repaint the page when JavaScript changes the DOM. It schedules work, and that work runs later, batched, in a fixed order, against a deadline set by the display. The ordering is what makes rendering predictable: a script that mutates ten elements and reads nothing causes the same amount of work as a script that mutates the same ten elements in one loop, because the pipeline collapses the mutations into one pass. Understanding the order also explains why some changes are cheap and others are not, and why the same change can be cheap in one element and expensive in another.

The stages and the thread that owns each

Four stages run on the main thread, in sequence:

  1. Style. The browser resolves, for every element that needs it, the winning declaration for every property: cascade, inheritance, and computed values. The output is a computed style per element.
  2. Layout. Boxes are built from the styled tree, and geometry (positions and sizes) is computed for each. The output is a fragment tree: for each box, an offset and a size relative to its containing block.
  3. Pre-paint and paint. The browser walks the painted content and records display lists: ordered sequences of drawing commands such as “fill this rectangle with this colour” or “draw this glyph run at this offset”. No pixels are produced yet.
  4. Commit. The layer tree, with its display lists, is handed to the compositor.

After commit, two other groups of threads finish the frame. Raster workers execute display lists into bitmaps, tile by tile. The compositor thread assembles the tiles of each layer, applies compositor-side transforms, and hands the result to the GPU for display.

The split matters because the main thread is shared with JavaScript, and the compositor is not. Anything the compositor can do alone can continue while a script is running.

Frame deadlines are shorter than the refresh interval

A display at 60 Hz presents a new frame every 16.7 ms; at 120 Hz, every 8.3 ms. The interval is not the main thread’s budget. The browser must composite, submit to the GPU, and have the result ready before the vertical blanking interval, so the usable main-thread slice is smaller than the interval, and it shrinks further when the compositor has other work. The practical rule is that all four main-thread stages above, plus any JavaScript that ran in the same task, must fit inside one interval; overflow moves the whole frame to the next vsync and the display repeats the previous one.

Before the first frame, the same stages are gated by resources rather than by deadlines: a stylesheet or a synchronous script can hold the pipeline at its first step, which is the dependency chain described in Render-Blocking CSS and Parser-Blocking Scripts.

Work is scheduled per frame, not per change. A script that writes to element.style.width a thousand times in a loop produces one style pass, because the invalidation is recorded and the pipeline is not entered until the frame is due. This batching is the reason the read-write interleaving described in Forced Synchronous Layout is so much more expensive than it looks: each read that needs fresh geometry forces the accumulated writes through layout early, destroying the batch.

Style resolution is a selector match, not a tree walk per rule

Style recalc starts from the elements marked dirty and walks their subtrees. For each element, the engine collects declarations that match, sorts them by origin, specificity, and order, and computes the result. Selector matching runs right to left: for .card .title, the engine finds elements matching .title and then tests ancestors for .card, so the cost is bounded by the number of candidate elements rather than by the number of rules in the stylesheet.

Two consequences follow. First, a change to a class on a container invalidates the whole subtree beneath it, because descendants may inherit or may be matched by descendant selectors; a change to a leaf invalidates only that leaf and its children. Second, custom property changes are more expensive than they appear: a registered custom property used by many declarations can invalidate style for every element that references it, and an unregistered one is inherited, so a change at the root dirties the tree.

The computed style is also where inheritance is resolved. Setting font-size on <html> changes the computed value of nearly every element on the page, which means the next layout is a full-document layout rather than a partial one.

Layout computes geometry under constraints

Layout is a constraint-solving pass, not a simple measurement. Each box receives a containing block and a set of constraints (available width, whether the height is definite), lays out its children, and returns a size. Block layout, inline layout, flex, grid, and table layout each have their own algorithm, but they share the property that a size change near the root can propagate arbitrarily far down: a wider container may reflow every paragraph inside it.

Engines limit the damage with dirty bits. An element whose own geometry changed is marked for layout; the engine walks up to the nearest ancestor whose layout is independent of the change (roughly: the nearest box with a fixed size or an established formatting context) and lays out from there. This containment boundary is why the properties in CSS Containment and Content Visibility matter beyond the paint stage.

The subtree that must be laid out is bounded by the boundary, but the cost per box is not constant. Inline layout requires line breaking, which depends on font metrics and on the width available; a change to the width of a paragraph re-breaks every line in it. Resizing a table can require measuring intrinsic widths, which is close to a full pass over the table’s contents.

Paint records commands; raster produces pixels

Paint produces a display list per paint layer. The list is a flat, ordered record of drawing operations with the geometry already resolved to layer-local coordinates. Because the list is a data structure rather than a bitmap, the browser can:

  • invalidate a small region and re-record only the display items that intersect it;
  • hold lists for offscreen content without rasterizing it;
  • rasterize at whatever scale the compositor needs, which is how pinch-zoom stays sharp.

Display lists also make the paint stage cheaper than people expect and the raster stage more expensive. A repaint of a complex subtree may re-record thousands of display items, but raster workers execute them in parallel across tiles, so the cost spreads over cores. A tile is commonly 256×256 or 512×512 pixels; at four bytes per pixel, a 512×512 tile occupies about 1 MiB of bitmap memory, which sets the scale for memory budgets on long pages.

Compositing is what can be moved off the main thread

The layer tree is a hierarchy of composited layers, each with its own transform and opacity. The compositor can change a layer’s transform or opacity and re-composite without asking the main thread, which is why transform and opacity animations keep running while JavaScript occupies the main thread, and why animating top or width cannot. A property that affects layout must return to the main thread every frame.

The catch is that layer promotion is not free. Each layer needs its own tiles and its own raster work, and a page with hundreds of promoted elements spreads raster capacity thin and increases the memory the compositor holds. will-change: transform is a hint with a persistent cost, not a one-shot one: it keeps the layer alive as long as the declaration applies. Compositing and Layer Promotion works through the promotion rules and the conditions under which the browser demotes a layer again.

Which stages a change can skip

The shortest useful summary of the pipeline is the list of stages each kind of change triggers:

ChangeStyleLayoutPaintComposite
color, background-color, box-shadowyesnoyesyes
width, height, font-size, top, marginyesyesyesyes
transform, opacity on a composited layernononoyes
transform on a non-composited elementyesnoyesyes
text content inside a fixed-size boxyespossiblyyesyes

The table is a map of the minimum work, not the guaranteed work. A transform change on an element that is not composited may force promotion, which costs raster and memory; a background-color change on an element with many descendants that paint separately may re-record a large display list. The invalidation granularity depends on how the engine grouped display items, which is why the same CSS can behave differently in different engines and, within one engine, at different subtree sizes.

The measurement question follows directly: since stages can be skipped but cannot be reordered, attributing time to a stage is a matter of instrumenting the pipeline rather than guessing from the property changed. Tracing and Attributing Rendering Work covers the trace categories and the timing APIs that separate style, layout, paint, and raster for a specific frame.

One boundary case is worth stating because it breaks the table’s optimism. Offscreen content still participates in style, and, depending on the engine and the properties involved, may participate in layout even when it is never painted. A page whose cost is dominated by offscreen subtrees cannot be fixed by paint-level tricks; it needs the work-skipping mechanisms, and the frame budget has to be measured with raster included, not just script.

// Each callback runs before style and layout for the frame,
// so a write here is batched with the frame's other writes.
let last = performance.now();
function tick(now) {
  const elapsed = now - last;
  last = now;
  updateAnimation(elapsed);   // writes only, no geometry reads
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

The loop above does its work at the one point in the frame where writes are guaranteed to be batched and where the timestamp corresponds to the frame’s start time, which is the only timestamp that can be compared meaningfully across frames. Reading geometry inside it forces layout before the callback returns, and the resulting cost lands inside the same frame budget.

References

  1. HTML Standard — WHATWG
  2. CSS Display Module Level 3 — W3C
  3. Rendering performance — web.dev
  4. CSS Containment Module Level 2 — W3C