Rendering pipeline investigation
Change one property. Watch exactly which stages of the pipeline are invalidated, and which are not.
- Level
- RESEARCHER
- Read time
- 11 min
- Experiment
- Available
- Type
- SIMULATION
The question
Why is animating transform cheap while animating top is expensive, when both move the element by the same number of pixels?
Hypothesis
Different properties invalidate different stages. top forces layout for a subtree; transform can be handled by the compositor without touching layout or paint at all.
Method
Laboratory available
The instrument for this investigation runs in the laboratory, where the controls, the live model and the observation log share one workstation.
Enter the laboratoryPick a property to mutate. The bench lights up the pipeline stages that must re-run and estimates the per-frame cost.
What we observed
| Property | Style | Layout | Paint | Composite |
|---|---|---|---|---|
| width / height / top / margin | yes | yes | yes | yes |
| font-size | yes | yes | yes | yes |
| color / background-color | yes | no | yes | yes |
| box-shadow / border-radius | yes | no | yes | yes |
| transform | yes | no | no | yes |
| opacity | yes | no | no | yes |
| filter | yes | no | no | yes |
| scroll position | no | no | no | yes |
Why it happens
The pipeline
- 01Parse: HTML → DOM, CSS → CSSOM.
- 02Style: match selectors and compute the used value of every property for every element.
- 03Layout: compute geometry, position and size of every box.
- 04Pre-paint / paint: record a display list of draw commands per layer.
- 05Raster: turn the display list into pixels, often on the GPU, often in tiles.
- 06Composite: assemble layers into a frame and hand it to the display.
Layout is the expensive stage because it is not local: changing one box can move its siblings, its parent's height, and everything after it in flow. Modern engines invalidate subtrees rather than the whole document, and contain: layout lets you promise that a subtree's layout cannot affect the outside, turning a document-wide invalidation into a local one.
// Bad: every read after a write forces a synchronous layoutfor (const el of items) { el.style.height = el.offsetHeight + 10 + "px";}// Better: batch reads, then batch writesconst heights = items.map((el) => el.offsetHeight);items.forEach((el, i) => { el.style.height = heights[i] + 10 + "px";});Why does reading offsetHeight force layout?
Because it must return a number that is correct *right now*.
Further research
A 60Hz display gives roughly 16.7ms per frame, and the browser needs part of that for its own work, the common guidance is to keep main-thread work per frame under ~10ms. Miss it and you do not get a slightly late frame: you get no frame, and the previous one stays on screen for another 16.7ms.
References
- 01ChromiumLife of a pixel
- 02web.devRendering performance
- 03web.devAvoid large, complex layouts and layout thrashing