Skip to content
Browser Lab · LAB-01style / layout

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 laboratory

Pick a property to mutate. The bench lights up the pipeline stages that must re-run and estimates the per-frame cost.

What we observed

PropertyStyleLayoutPaintComposite
width / height / top / marginyesyesyesyes
font-sizeyesyesyesyes
color / background-coloryesnoyesyes
box-shadow / border-radiusyesnoyesyes
transformyesnonoyes
opacityyesnonoyes
filteryesnonoyes
scroll positionnononoyes
Invalidation by property (Chromium behaviour; other engines differ in detail)

Why it happens

The pipeline

  1. 01Parse: HTML → DOM, CSS → CSSOM.
  2. 02Style: match selectors and compute the used value of every property for every element.
  3. 03Layout: compute geometry, position and size of every box.
  4. 04Pre-paint / paint: record a display list of draw commands per layer.
  5. 05Raster: turn the display list into pixels, often on the GPU, often in tiles.
  6. 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.

example.jsjs
// 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";});
Layout thrashing: the same work, 100× more expensive
The Why MachineDEPTH 1 / 4

Why does reading offsetHeight force layout?

  1. 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

  1. 01ChromiumLife of a pixel
  2. 02web.devRendering performance
  3. 03web.devAvoid large, complex layouts and layout thrashing