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 type | Examples | Pipeline path |
|---|---|---|
| Layout-affecting | width, height, top, padding, margin | JS → Style → Layout → Paint → Composite (full path) |
| Paint-only | background-color, box-shadow, color | Skips Layout → Paint → Composite |
| Compositor-only | transform, opacity | Skips Layout and Paint → Composite only |
Syntax
/* Compositor-only - cheapest possible animation path */
.card {
transition: transform 0.2s, opacity 0.2s;
}
.card:hover {
transform: scale(1.03);
opacity: 0.95;
}Examples
// 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/widthfor movement or resizing instead oftransform, 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/clientWidthimmediately 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
transformandopacityinstead 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/scrollTopright after a style write as a layout-thrashing candidate worth double-checking.