Repaint vs reflow (layout) vs composite — what triggers what?
The browser pipeline: JS → Style → Layout (reflow) → Paint (repaint) → Composite. Layout is the most expensive; transform/opacity skip layout AND paint and run on the GPU. Avoid layout-thrashing read/write loops.
The browser's rendering pipeline has discrete stages, and which stages your change triggers determines whether the frame budget (16.6ms at 60fps) is spent or wasted.
Pipeline (every frame, in order):
- JS — your event handlers and rAF callbacks run.
- Style — recompute matched CSS rules and computed styles for affected elements.
- Layout (reflow) — compute geometry: position, size of every affected element. Cascades to children. Most expensive stage.
- Paint (repaint) — fill in pixels for each layer (text, colors, images, borders) into bitmaps.
- Composite — assemble layer bitmaps onto the screen. Runs on the GPU; very cheap.
What triggers each.
- Layout (reflow): width/height/top/left/font-size/display/position changes, adding/removing DOM nodes, reading layout-derived values (offsetTop, getBoundingClientRect, scrollY), changing classes that affect geometry. Cascades — change a parent, every child re-lays-out.
- Paint (no layout): color/background/box-shadow/border-radius (without size change), visibility.
- Composite-only:
transformandopacityon a promoted layer. Runs entirely on the GPU. The cheapest possible animation.
Layout-thrashing — the #1 perf bug. A read-then-write-then-read loop forces the browser to recompute layout multiple times in one frame:
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + "px"; // read offsetWidth → forces layout
} // write style → invalidates layout for next iteration
// Each iteration: layout, layout, layout. O(n) layouts.Fix: batch reads, then batch writes.
const widths = elements.map(el => el.offsetWidth); // one layout
for (let i = 0; i < elements.length; i++) {
elements[i].style.width = widths[i] + 10 + "px"; // pure writes
} // one layout at the end of the frameTools like fastdom formalize this read/write batching. React's commit phase already does this (commit all DOM mutations, then read layout in useLayoutEffect).
Promote a layer for animation.
will-change: transform (or transform: translateZ(0) historically) tells the browser to put this element on its own composited layer. Now transform: translateX(...) runs only the composite step — no layout, no paint. This is how Framer Motion and react-spring achieve 60fps animations.
Don't will-change everything — each layer costs GPU memory. Apply just before the animation, remove after.
Animate transform/opacity, never width/height.
- ❌
transition: width 200ms→ layout every frame → janky on complex pages. - ✅
transition: transform 200ms(e.g.,scaleX(0.5)) → composite only. - ✅
transition: opacity 200ms→ composite only.
For "expand a card" animations, use transform: scale + a clip mask, or the FLIP technique (First, Last, Invert, Play).
Other principles.
- Off-screen mutation. Mutate a detached node, then attach — no incremental layouts.
- Avoid synchronous layout reads in scroll/resize handlers — they force layout up to 60×/sec.
content-visibility: autoskips layout/paint for off-screen elements (huge win for long pages).- Containment.
contain: layout painton cards tells the browser "changes inside don't affect outside" — limits cascade scope.
Devtools. Performance panel → record → look for purple "Layout" and green "Paint" bars. If you see thick stacks of those during scroll, you're thrashing.
Code
Follow-up questions
- •Why does transform animate cheaper than left/top?
- •What does will-change: transform actually do?
- •How do you detect layout thrashing in DevTools?
- •What's the FLIP technique?
Common mistakes
- •Animating width/height/top/left — forces layout each frame.
- •Reading offsetWidth inside a write loop — layout per iteration.
- •Sprinkling will-change everywhere — GPU memory bloat.
- •Synchronous layout reads in scroll handlers without rAF batching.
Performance considerations
- •transform/opacity on a promoted layer is the only GPU-accelerated path on most browsers.
- •content-visibility: auto skips work entirely for off-screen sections — biggest single win for long pages.
Edge cases
- •Promoting too many layers exhausts GPU memory and CAN regress perf.
- •transform on a non-promoted element still composites but may trigger paint of a parent.
- •Reading scrollTop forces layout — cache when iterating.
Real-world examples
- •Smooth drag-and-drop libraries (dnd-kit) move with transform, not left/top.
- •Framer Motion promotes layers automatically for animated components.