Deep Dive: The Browser Rendering Pipeline
Related: 03 CSS, 16 Performance
Opening question
Why does changing transform run smoothly at 60 fps while changing width stutters? What actually happens between "the DOM changed" and "the pixels on screen changed"?
Mental model
HTML ──parse──▶ DOM ─┐
├─▶ Style (computed styles) ─▶ Layout (geometry) ─▶ Paint (display list)
CSS ──parse──▶ CSSOM┘ │
▼
Composite (layers → GPU, on the compositor thread) ─▶ ScreenChanging a property triggers the pipeline from a particular stage onward:
| What changes | Example properties | Stages that re-run |
|---|---|---|
| Geometry | width, top, font-size, adding elements | Style → Layout → Paint → Composite |
| Appearance only | color, background, box-shadow | Style → Paint → Composite |
| Compositor only | transform, opacity (on their own layer) | Composite |
Because compositing runs on the compositor thread, transform/opacity animations stay smooth even while the main thread is busy running JavaScript.
Detailed mechanism
- Parsing: HTML is parsed incrementally. A plain
<script>stops the parser, while the preload scanner keeps looking for other resources to download early. - Style: the browser matches selectors to elements (right to left), then computes values. Complex selectors plus a large DOM = expensive style recalculation.
- Layout: computes position and size based on the formatting context (block, inline, flex, grid). Layout can be local or global depending on the boundaries of the change (
contain: layouthelps). - Paint: produces a list of drawing commands per layer, which is then rasterized (often on the GPU).
- Layers: elements are promoted to their own layer because of
will-change, 3Dtransform, video, canvas, etc. Too many layers = bloated GPU memory. - Forced synchronous layout: reading geometry (
offsetWidth,getBoundingClientRect) after writing styles forces the browser to run layout right then. Do it in a loop and you get layout thrashing.
Experiments
- Cost per property. Animate a box with
left, then withtransform: translateX. Record in the Performance panel and turn on Paint flashing and Layer borders in the Rendering tab. - Layout thrashing.jsCompare the timing with 1,000 elements.
const items = [...document.querySelectorAll(".item")]; // Bad: interleaved reads and writes → layout on every iteration items.forEach(el => { el.style.width = el.offsetWidth + 10 + "px"; }); // Good: read everything first, then write everything const widths = items.map(el => el.offsetWidth); items.forEach((el, i) => { el.style.width = widths[i] + 10 + "px"; }); - Busy main thread vs the compositor. Run a heavy 2-second loop while a
transformanimation and aleftanimation are running. Which one keeps moving? content-visibility: auto. Apply it to a long page and measure the initial render time.
Practical implications
- Animate only
transformandopacitywhenever possible. - Batch layout reads and style writes separately (or use rAF for the writes).
- Use
will-changesparingly, not on every element. - Use
containandcontent-visibilityto limit the scope of layout/paint work.