Reducing reflows and repaints
Reflow = browser recomputes geometry; repaint = re-rasterizes pixels. Batch DOM writes, separate reads from writes (avoid layout thrashing), animate `transform`/`opacity` (composite-only), and use `will-change` / `contain` sparingly to isolate work. Use rAF for visual updates; debounce reads with `getBoundingClientRect` once per frame.
The browser's rendering pipeline is roughly:
JS → Style → Layout (reflow) → Paint → CompositeCheap operations skip stages on the right. Compositing-only properties (transform, opacity, filter partially) skip Layout AND Paint. Paint-only properties (background-color, box-shadow, color) skip Layout. Layout-triggering properties (width, height, top, left, margin, font-size, anything that changes box geometry) walk the full pipeline.
Rule 1: animate transform / opacity, not top / width.
/* Bad — reflows every frame */
.slide { transition: left 300ms; }
.slide:hover { left: 100px; }
/* Good — compositor-only */
.slide { transition: transform 300ms; }
.slide:hover { transform: translateX(100px); }transform and opacity are GPU-promoted on most engines — animations run on the compositor thread, independent of JS.
Rule 2: don't layout-thrash. Reading a layout property after writing one forces a synchronous reflow:
// Catastrophic — forces N reflows
for (const el of items) {
el.style.height = el.offsetHeight + 10 + "px";
}
// Fix — batch reads, then writes
const heights = items.map(el => el.offsetHeight);
for (let i = 0; i < items.length; i++) {
items[i].style.height = heights[i] + 10 + "px";
}The "forced reflow" attribute in the Performance panel always points back to a read-after-write pattern. The geometry-reading APIs that trigger reflow: offsetTop/Left/Width/Height, scrollTop/Left/Width/Height, clientTop/Left/Width/Height, getBoundingClientRect, getComputedStyle.
Rule 3: use requestAnimationFrame for visual writes.
requestAnimationFrame(() => {
el.style.transform = `translateX(${x}px)`;
});rAF schedules the write right before the next paint, so the browser batches with its own work. scroll handlers especially benefit — read in the handler, write in rAF.
Rule 4: will-change and contain — used carefully.
will-change: transformhints to the browser to promote the element to its own compositor layer. But layers cost memory — promote everything and you OOM mobile devices. Use only on elements that are about to animate, and remove the hint when done.contain: layout paint styletells the browser an element's internals can't affect siblings. Critical for big lists: a row's reflow doesn't propagate to the rest of the table.contain: strict= layout + size + style + paint. Most aggressive; requires a known size.content-visibility: auto— skip rendering off-screen children entirely (browser does virtualization-lite for you).
Rule 5: avoid expensive CSS that paints a lot.
box-shadowwith large blur radius.border-radiuson big rectangles + scroll (some engines can't tile-cache).filter: blur(...)on large surfaces.backdrop-filter— beautiful, expensive; profile before shipping it everywhere.
Rule 6: layout containment via fewer top-level mutations.
Adding/removing 50 nodes in a loop forces a reflow each time. Build the subtree in a DocumentFragment (or in memory via React) and insert once.
Where to look in DevTools.
- Performance panel → Frames → look for purple "Layout" and green "Paint" blocks.
- "Forced reflow" warnings — click to see the JS that caused it.
- Layers panel — count promoted layers, watch memory.
- Paint Flashing (Rendering tab) — highlights re-painted regions in green; toggle to see which interactions cost paint.
The senior framing. Reducing reflows isn't about micro-optimizing every property; it's about knowing the pipeline well enough to predict which interactions are cheap. Once you can name the stage each property triggers, the right choices fall out.
Follow-up questions
- •Why does reading offsetHeight after writing style trigger a forced reflow?
- •What's the cost of `will-change` and when should you remove it?
- •Difference between `contain: layout` and `contain: paint`?
- •How does `content-visibility: auto` compare to virtualization?
Common mistakes
- •Animating `width`, `top`, `left` instead of `transform`.
- •Reading geometry inside a setState that just wrote to the DOM.
- •Adding `will-change` to many elements `just in case`.
- •Heavy `box-shadow` / `filter` over the entire viewport during scroll.
Performance considerations
- •GPU layers cost VRAM — mobile Chrome will drop them under pressure, causing visible jank.
- •Composite-only animations keep running smoothly even when the main thread is busy.
- •Mobile devices have far less paint bandwidth than desktop — test there.
Edge cases
- •iOS Safari treats fixed-position differently during scroll — can cause double-paint.
- •Subpixel rendering — `transform: translate(0.5px, 0.5px)` can cause blurry text.
- •Reading `scrollHeight` on a virtualized container that's mid-render can force layout.
Real-world examples
- •Sticky headers that re-flow on scroll instead of using `position: sticky` + transform.
- •Drag-and-drop libraries that use transform-based positioning to keep frame rate at 60fps.