Cloud Tech by Victor
FrontendIntermediate

Browser Rendering Pipeline

Why animating transform and opacity can skip layout and paint entirely while animating width or top can't, and how alternating writes and reads to layout properties in a loop forces the browser to recalculate layout over and over.

Updated 2026-07-243 min read

Overview

Every visual update runs through the same five-stage pipeline, JavaScript, Style, Layout, Paint, Composite, but not every change needs all five stages. A property that changes an element's geometry (width, top, padding) forces the full path: layout recalculates, paint redraws pixels, composite assembles the result. A property like transform or opacity changes neither geometry nor pixel content, only how an already-painted layer is displayed, so the browser can skip straight to the cheapest stage, composite, run entirely on the compositor thread. Layout thrashing is a specific, avoidable performance bug born from this same pipeline: alternating a style write with a layout-property read (offsetWidth, clientHeight) in a loop forces a synchronous layout recalculation on every single iteration, instead of the one recalculation the browser would otherwise batch naturally.

Quick Reference

Property typeExamplesPipeline path
Layout-affectingwidth, height, top, padding, marginJS → Style → Layout → Paint → Composite (full path)
Paint-onlybackground-color, box-shadow, colorSkips Layout → Paint → Composite
Compositor-onlytransform, opacitySkips Layout and Paint → Composite only

Syntax

css
/* Compositor-only - cheapest possible animation path */
.card {
  transition: transform 0.2s, opacity 0.2s;
}
.card:hover {
  transform: scale(1.03);
  opacity: 0.95;
}

Examples

javascript
// Layout thrashing - each iteration writes then immediately
// reads a layout property, forcing a synchronous recalculation
// every single time through the loop.
for (const el of elements) {
  el.style.width = '100px';
  console.log(el.offsetWidth); // forces layout right here, every time
}

// Fixed - batch every write, then batch every read.
for (const el of elements) el.style.width = '100px';
for (const el of elements) console.log(el.offsetWidth);

Prefer transform/opacity for anything animated

They're the only properties that can skip both layout and paint entirely, running on the compositor thread. Animating top/left/width instead forces a full layout-paint-composite cycle on every frame, which is where most janky animations actually come from.

Visual Diagram

Common Mistakes

  • Animating top/left/width for movement or resizing instead of transform, forcing a full layout-paint-composite cycle every frame.
  • Alternating DOM writes and layout-property reads inside a loop, causing a synchronous layout recalculation on every iteration.
  • Assuming all CSS properties are equally cheap to animate, when the actual pipeline path taken differs enormously by property.
  • Reading offsetHeight/clientWidth immediately after a style change without realizing that read itself forces layout to happen right then, not lazily later.

Performance

  • A full layout-paint-composite cycle is meaningfully more expensive than a compositor-only update; this difference is the single biggest lever for animation smoothness (avoiding jank).
  • Layout thrashing turns what should be one batched layout recalculation into one per loop iteration, a pattern that scales badly and shows up directly as dropped frames on longer lists.

Best Practices

  • Animate transform and opacity instead of layout-affecting properties whenever the visual effect (move, scale, fade) can be expressed that way.
  • Batch all DOM writes together, then all layout-property reads together, never interleave them in a loop.
  • Use browser devtools' performance/rendering panels to confirm whether a given change is actually taking the layout path or the cheaper composite-only path, rather than assuming.
  • Treat any code that reads offsetWidth/offsetHeight/clientWidth/scrollTop right after a style write as a layout-thrashing candidate worth double-checking.

Interview questions

What are the five stages of the browser rendering pipeline, and which ones does a change to a property like width actually have to go through?

The pipeline runs JavaScript, Style calculation, Layout, Paint, and Composite, in that order. Changing a property that affects geometry, `width`, `height`, `position`, forces the browser through every stage: layout has to be recalculated (since the element's size or position changed), then paint (since pixels changed), then composite. That full path is why layout-affecting properties are the most expensive to animate, every frame re-runs the whole pipeline, not just a cheap final step.

Why can animating transform or opacity skip layout and paint entirely, while animating top or width cannot?

transform and opacity don't change an element's geometry, its size or position in the document flow, or repaint its actual pixel content, they only change how an already-painted layer is displayed (moved, scaled, faded) during compositing. Because nothing about the element's layout or pixels actually changed, the browser can skip straight to the composite stage, the cheapest, fastest path in the pipeline, and handle it on the compositor thread. `top` and `width` do change geometry, so there's no way to skip layout, the browser has no shortcut for "the size changed but skip figuring out the new size."

What is layout thrashing, and why does writing then reading a layout property in a loop specifically cause it?

Layout thrashing happens when code alternates writing a style (which invalidates the current layout) and reading a layout-dependent property like `offsetWidth` (which forces the browser to immediately recalculate layout synchronously to answer that read accurately), repeated across many elements in a loop. Each read-after-write pair forces a fresh, synchronous layout recalculation instead of letting the browser batch and defer that work to its normal rendering schedule, because the code demanded an up-to-date value mid-loop. The fix is mechanical: batch every write first, then batch every read afterward, so layout is only recalculated once instead of once per element.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement