Reflow and repaint are not the same, and the difference is your frame budget
Not all style changes cost the same. Change an element’s color and the browser
only has to repaint — re-fill the pixels, cheap. Change its width, and the
browser has to reflow — recompute the geometry of that element and, often,
everything after it in the document, then repaint. Reflow is the expensive one,
and the classic performance bug is triggering it over and over inside a loop.
With a 60fps target you have about 16 milliseconds per frame; a reflow storm
blows that budget and the page visibly stutters.
Layout thrashing: the read-write-read trap
The worst case is not one reflow — it is forcing many by interleaving reads and
writes. When you write a style the browser can batch it and reflow once, later.
But if you then read a geometry property like offsetHeight, the browser must
flush the pending changes and reflow now to give you an accurate answer. Do
that in a loop and you force a synchronous reflow on every iteration:
// BAD — reads offsetWidth after each write, forcing a reflow per item
for (const el of items) {
el.style.width = el.offsetWidth + 10 + "px"; // read forces reflow, write dirties it
}
Batch the reads, then batch the writes
The fix is to separate the phases: read every value first (one reflush at most), then apply every write (batched into one reflow). The measurements are all taken against the same clean layout, and the mutations settle together:
// GOOD — one read phase, then one write phase
const widths = items.map((el) => el.offsetWidth); // all reads
items.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px"; // all writes, batched
});
For animation, go one step further and stay on properties that skip layout
entirely. transform and opacity can be handled by the compositor on their own
layer — they neither reflow nor repaint the main content, which is why a
transform: translateX() animation is smooth where animating left janks:
/* compositor-friendly: no reflow, no repaint of siblings */
.card { transition: transform 200ms; }
.card:hover { transform: translateY(-4px); }
The rule of thumb is a hierarchy of cost: compositing (transform/opacity) is
cheapest, repaint is next, reflow is dearest, and forced synchronous reflow in a
loop is the thing that actually drops frames. When a list scrolls smoothly until
it doesn’t, this is almost always why — you are reading geometry mid-mutation.
A useful habit is to reach for the browser’s own tools before guessing: the
Performance panel marks forced reflows in purple and names the line that
triggered each one, so “why is this janky” becomes a specific stack trace rather
than a hunch. And for measurements you genuinely need during a frame, batch them
behind requestAnimationFrame, which runs your callback just before layout so a
single read reflects every pending write at once.
The infinite-scroll exercise is where that lesson bites hardest, because a scroll
handler that thrashes layout turns a long list into a slideshow.