Skip to main content

When reflow and repaint occur in browser

Reflow forces the browser to recalculate element geometry when layout-affecting properties change. Repaint redraws pixels for visual changes that do not shift any element.

Theory

TL;DR

  • Reflow is like rearranging furniture: when one piece moves, everything around it can shift too
  • Repaint is like repainting a wall: nothing moves, only pixel colors change
  • Every reflow triggers a repaint, but a repaint does not trigger a reflow
  • Width or height change = reflow + repaint. Color change = repaint only
  • transform and opacity animations can skip both entirely (compositor-only)

Quick Example

html
<div id="box" style="width:100px; height:100px; background:red; margin:20px;"></div> <div id="sibling">Sibling element</div> <script> const box = document.getElementById('box'); // Repaint only: color does not affect geometry, sibling stays in place box.style.background = 'blue'; // Reflow + repaint: height change shifts the sibling element down box.style.height = '200px'; </script>

Color change is fast because the browser only redraws pixels. Height change forces a full layout recalculation: the browser walks up to the parent, then down to the sibling, updating coordinates for everything in scope.

How the Browser Processes Changes

Browsers build a render tree from the DOM and CSSOM. During layout (reflow), the engine assigns exact pixel coordinates and sizes to every node using block formatting contexts. That result gets rasterized to screen via GPU in Blink/Chromium, or CPU in Gecko.

When you change something, the browser marks affected nodes as "dirty". On the next paint frame (roughly 16ms at 60fps), it recalculates the layout subtree starting from the changed node, propagates up to ancestors and down to descendants and siblings. That propagation is what makes reflow expensive on complex pages.

Repaint skips the geometry pass entirely. The engine re-rasterizes only the pixels for the visual property that changed. No neighbor elements are touched.

Key Difference

Reflow rebuilds element geometry from the changed node outward: parents, children, and siblings can all shift. Repaint only updates the pixels of the element itself. This is why display: none causes a reflow (the element leaves document flow and its space collapses) while visibility: hidden causes only a repaint (space stays reserved, nothing shifts).

When Reflow Occurs

  • Adding or removing DOM elements
  • Changing width, height, margin, padding, border-width
  • Changing position, top, left, float
  • Changing font-size, font-family, line-height
  • Switching display from none to block or back
  • Window resize
  • Reading layout properties: offsetWidth, offsetHeight, getBoundingClientRect()

That last point trips many developers. Reading a layout property mid-frame forces the browser to flush its dirty-node queue and recalculate layout immediately, even during an animation.

When Repaint Occurs (Without Reflow)

  • Changing color, background-color, background-image
  • Changing border-color, box-shadow, outline
  • Switching visibility (not display)
  • Changing opacity on non-composited elements

Forced Synchronous Reflow

html
<div id="anim" style="width:100px; transition:width 1s; background:blue;"></div> <script> const el = document.getElementById('anim'); el.style.width = '200px'; // queues a dirty node, reflow not yet done // Reading offsetWidth here forces the browser to flush immediately // This destroys animation smoothness console.log(el.offsetWidth); // forced synchronous reflow // Fix: batch all reads before any writes in the same frame </script>

This pattern shows up in Chrome DevTools as yellow "Layout" spikes in the performance flame chart. React's useLayoutEffect intentionally runs after reflow and before repaint, which is exactly why it blocks the paint.

Common Mistakes

Read-modify in a loop

js
// Bad: each offsetTop read flushes the dirty queue - N reflows for N elements for (let i = 0; i < elements.length; i++) { elements[i].style.top = elements[i].offsetTop + 10 + 'px'; } // Fix: batch reads first, then writes const tops = elements.map(el => el.offsetTop); // one reflow elements.forEach((el, i) => { el.style.top = tops[i] + 10 + 'px'; // writes only });

Animating layout properties

js
// Bad: triggers reflow on every animation frame element.style.left = currentX + 'px'; // Good: compositor handles this, zero reflow element.style.transform = `translateX(${currentX}px)`;

Table layouts with dynamic rows

css
/* Adding one row reflows all cells in the table grid */ table { display: table; } /* Fix: use flexbox or grid for dynamic layouts */ .list { display: flex; flex-direction: column; }

Font loading without font-display

css
/* Without font-display, text reflows when the font finally loads */ @font-face { src: url(font.woff2); font-display: swap; /* prevents layout shift on font load */ }

Real-world Usage

  • React: useLayoutEffect runs post-reflow, pre-repaint for DOM measurements, used in react-window for virtual list calculations
  • GSAP: xPercent and scaleX avoid reflow vs animating left or width
  • Swiper.js: contain: layout on slide containers isolates carousel reflow from the rest of the page
  • Chart.js: renders to <canvas>, which skips DOM reflow entirely for chart animations

I once debugged a carousel where reading offsetWidth inside the animation loop caused 60 reflows per second. Moving the read outside the loop dropped layout time from ~60ms to under 2ms per frame.

Follow-up Questions

Q: What is the difference between reflow, repaint, and compositing?
A: Compositing blends already-painted layers on the GPU without touching the DOM. transform and opacity on promoted layers only composite. Reflow handles geometry, repaint handles pixels, compositing just blends layers.

Q: How do you measure reflows in the browser?
A: Open Chrome DevTools, go to the Performance panel, record an interaction, then look for "Layout" events over 1ms. "Recalculate Style" is CSSOM work, not a reflow.

Q: Does position: fixed remove an element from reflow?
A: No. Fixed elements still cause reflow. contain: layout is what actually isolates reflow propagation on a component.

Q: How does CSS contain change reflow propagation?
A: contain: layout tells the browser that nothing inside the element affects layout outside it. This stops reflow from walking up the tree, which matters for web components and large component libraries.

Q: Why does display: none cause reflow but visibility: hidden does not?
A: display: none removes the element from document flow entirely. Siblings shift, parents resize. visibility: hidden keeps the space, so nothing around it moves.

Q: In React StrictMode, why do double reflows happen?
A: StrictMode intentionally invokes effects twice in development to surface side effects. In production, each lifecycle runs once.

Examples

Color Change vs. Size Change

html
<!DOCTYPE html> <html> <body> <div id="box" style="width:100px; height:100px; background:red; margin:20px;">Box</div> <div id="sibling" style="background:lightgray; padding:10px;">Sibling</div> <script> const box = document.getElementById('box'); // Repaint only: sibling stays exactly where it is box.style.background = 'blue'; setTimeout(() => { // Reflow: sibling shifts down as box grows taller box.style.height = '200px'; }, 1000); </script> </body> </html>

Color swap goes through repaint only. Height change recalculates positions for both the box and the sibling. At 100 elements changing 60 times per second, the difference in frame time is not subtle.

Batching Reads and Writes in a Dashboard Grid

js
// Context: resizing multiple cards in a dashboard grid // Bad: interleaved reads and writes, one reflow per card function resizeCardsBad(cards) { cards.forEach(card => { const h = card.offsetHeight; // read forces reflow card.style.height = h + 50 + 'px'; // write }); } // Good: batch all reads first, then all writes (one reflow total) function resizeCardsGood(cards) { const heights = cards.map(card => card.offsetHeight); // all reads cards.forEach((card, i) => { card.style.height = heights[i] + 50 + 'px'; // all writes }); } // Alternative: ResizeObserver removes explicit layout reads from your code const observer = new ResizeObserver(entries => { entries.forEach(entry => { const { height } = entry.contentRect; // browser-provided, no forced reflow entry.target.setAttribute('data-height', height); }); }); cards.forEach(card => observer.observe(card));

The batched version reduces N reflows to 1. ResizeObserver removes explicit layout reads entirely and lets the browser schedule measurement work on its own.

Compositor-only Animation (Zero Reflow, Zero Repaint)

js
// Bad: left triggers a full reflow on every animation frame function animateBad(element) { let pos = 0; function step() { pos += 2; element.style.left = pos + 'px'; // reflow every frame if (pos < 300) requestAnimationFrame(step); } requestAnimationFrame(step); } // Good: transform runs on the compositor thread, zero reflow, zero repaint function animateGood(element) { let pos = 0; element.style.willChange = 'transform'; // promote to its own GPU layer function step() { pos += 2; element.style.transform = `translateX(${pos}px)`; // compositor-only if (pos < 300) requestAnimationFrame(step); } requestAnimationFrame(step); }

At 60fps, the bad version runs 60 reflows per second and can block the main thread. The good version runs zero. This is usually the single biggest animation performance improvement in frontend codebases.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?