"Why does this page lag when I scroll the list?" Almost always the answer lies in how the browser turns your HTML and CSS into pixels — the critical rendering path. Understanding reflow and repaint is what separates those who just make the screen appear from those who make it appear fast.
The critical rendering path
To draw a pixel, the browser follows an assembly line of steps:
HTML → DOM ┐
├→ Render Tree → Layout → Paint → Composite
CSS → CSSOM┘
The DOM (structure) and the CSSOM (styles) combine into the render tree (what is visible and how). Layout calculates the position and size of each element, Paint fills in the pixels (color, text, shadow) and Composite stacks the layers on screen.
Reflow vs repaint
When something changes, the browser doesn't redo everything. There are two levels of rework:
- Reflow (layout): recalculating geometry — position and size. Expensive, because it can affect neighboring elements and even the entire page.
- Repaint: redrawing pixels without touching geometry — changing a background color, for example. Cheaper than reflow.
Analogy: reflow is redrawing the house's floor plan (it moves walls); repaint is just repainting a wall that already exists.
| Reflow (layout) | Repaint | |
|---|---|---|
| Recalculates geometry? | Yes | No |
| Cost | High | Medium |
| Triggered by | width, top, font-size, add/remove node | color, background, visibility |
What triggers reflow
Changing dimensions, position, font, adding or removing elements, resizing the window — everything forces reflow. And there's a treacherous detail: reading certain properties (like offsetHeight, getBoundingClientRect) forces the browser to do a reflow right then to give you the up-to-date value.
Layout thrashing: the silent villain
Layout thrashing happens when you interleave reads and writes in a loop, forcing repeated reflow. The browser wanted to batch the changes, but each read forces it to recalculate immediately.
// BAD: alternating read and write → reflow on every iteration
for (const el of itens) {
el.style.height = el.offsetHeight + 10 + 'px';
}
// GOOD: read everything, then write everything
const alturas = itens.map(el => el.offsetHeight);
itens.forEach((el, i) => el.style.height = alturas[i] + 10 + 'px');
What to do in practice
- Batch reads and writes: read all measurements, then apply all changes.
- Animate with
transformandopacity: they run at the composite step, with no reflow or repaint. - Avoid forcing synchronous layout: don't read
offsetTopright after writing a style. - Virtualize long lists: render only what's visible (windowing) instead of 10,000 rows.
- Use
will-changesparingly to promote animated elements to their own layers.
Fast rendering rarely comes from "making the JS faster" — it comes from asking the browser for less layout work.
Interviews commonly ask you to explain the difference between reflow and repaint, or why animating transform is better than animating top. Day to day, it shows up when a large list stutters on scroll or an animation gets janky.
Conclusion
The browser follows DOM → CSSOM → layout → paint → composite, and each change can cost a reflow (expensive) or a repaint (cheaper). Batch reads and writes, animate at the composite step and virtualize lists. This local care is the basis for the broader view of Core Web Vitals.