What causes reflow and repaint, and how do you keep animations at 60fps?
Take a moment.
Say your answer out loud or write it down first — retrieval is what makes it stick. Reveal when you're ready.
Short answer
Reflow recalculates geometry (layout) when sizes or positions change; repaint redraws pixels without moving anything. Both are expensive on the main thread. Smooth animation stays on the compositor — animate transform and opacity only, batch DOM reads/writes, and promote animated elements to their own layer.
Detailed explanation
Changing width, height, margin, font-size, or reading layout (offsetHeight, getComputedStyle) after a write forces synchronous reflow: the browser must recompute geometry for the element and potentially its whole subtree before continuing — and interleaved read/write loops (layout thrashing) repeat that cost per iteration. Repaint (color, background, visibility, box-shadow) skips geometry but still rasterizes pixels on the CPU. The compositor shortcut avoids both: properties like transform and opacity can be applied by the GPU to an already-painted layer, so frames stay at 16ms even on weak devices — DevTools' Layers panel shows which elements got promoted. The 60fps recipe: animate transform/opacity exclusively (translate instead of top/left, scale instead of width/height); batch reads then writes (or use requestAnimationFrame, and FastDOM-style separation) to avoid thrashing; contain damage with CSS containment (contain: layout paint) so changes don't invalidate the whole tree; prefer will-change sparingly to hint layer promotion (overuse exhausts GPU memory); and keep main-thread tasks under ~50ms so the event loop can still deliver input and frames — split long work with yielding or move it to a Web Worker. Measure with the Performance panel and the FPS meter, not by feel.
Example
/* ❌ layout + paint every frame (janky) */
.drawer { transition: width 0.3s, left 0.3s; }
/* ✅ compositor-only (smooth) */
.drawer {
transition: transform 0.3s, opacity 0.3s;
will-change: transform;
}
.drawer[hidden] { transform: translateX(-100%); opacity: 0; }
// ❌ layout thrashing: read after every write
// items.forEach((el) => { el.style.width = '100px'; sum += el.offsetWidth; });
// ✅ batch: write all, then read all
// items.forEach((el) => { el.style.width = '100px'; });
// const sum = items.reduce((a, el) => a + el.offsetWidth, 0);Interview tip
Define the three stages (layout → paint → composite) and which properties skip to the last one. The translate-instead-of-left swap is the one-line proof you apply it.
Common mistakes
- Animating width, height, top, or left and wondering why it janks.
- Reading layout inside a write loop, causing layout thrashing.
- Spraying will-change everywhere and exhausting GPU memory.