In short. Browsers batch layout work and do it once, later. Reading a geometry property — a height, a position, a scroll offset — cannot wait, so the browser stops and recalculates immediately. Do that inside a loop that also writes, and you force one full layout per iteration instead of one for the whole loop.
Why reading is the expensive operation
Changing a style does not lay anything out. The browser records that something is dirty and defers the recalculation until it needs to paint — which lets it collapse dozens of changes into a single pass.
Reading a geometry value breaks that arrangement, because the answer has to be correct now. If any pending change could affect it, the browser must apply everything outstanding and recompute layout synchronously before it can return a number. That synchronous recomputation is the forced reflow.
The counter-intuitive part is that the write is cheap and the read is expensive. Most people optimise the wrong half — batching DOM writes while leaving the reads scattered between them, which changes nothing because it is the read that triggers the work.

The shape of the bug
// Thrashing: read, write, read, write — one layout per element
for (const el of items) {
const h = el.offsetHeight; // forces layout: a write is pending
el.style.height = (h + 10) + 'px'; // dirties layout again
}
// Batched: every read first, then every write — one layout total
const heights = items.map((el) => el.offsetHeight); // one forced layout
items.forEach((el, i) => {
el.style.height = (heights[i] + 10) + 'px'; // no reads in between
});
Both versions do identical work to the page. The second is often an order of magnitude faster on a long list, and the difference is entirely in the ordering.
The cost scales with two things: how many elements you touch, and how expensive layout is for the document as a whole. That second factor is why the same loop can be imperceptible on a simple page and janky on a complex one — the loop did not change, the cost of each recalculation did.
What actually forces it
| Category | Examples |
|---|---|
| Box geometry | offsetWidth, offsetHeight, offsetTop, offsetLeft |
| Client box | clientWidth, clientHeight, clientTop, clientLeft |
| Scrolling | scrollTop, scrollLeft, scrollWidth, scrollHeight |
| Explicit measurement | getBoundingClientRect(), getClientRects() |
| Computed style | getComputedStyle() for layout-dependent properties |
| Side effects | focus(), scrollIntoView(), reading innerText |
The last row surprises people. innerText is layout-aware — it reflects what is actually rendered, so it needs layout to answer. textContent does not and is cheap; swapping one for the other is sometimes the entire fix.
Reading without forcing anything
Two APIs deliver measurements the browser has already computed, which sidesteps the problem instead of scheduling around it:
ResizeObserverreports an element's size when it changes, with the dimensions supplied in the callback. No property read, no forced layout.IntersectionObserverreports visibility and position relative to a root, which covers most of what scroll handlers are written to compute manually.
// Instead of measuring on every scroll event
new IntersectionObserver((entries) => {
for (const e of entries) {
e.target.classList.toggle('visible', e.isIntersecting);
}
}, { rootMargin: '200px' }).observe(el);
// Instead of polling offsetWidth
new ResizeObserver((entries) => {
for (const e of entries) {
const { width } = e.contentRect; // already measured
}
}).observe(el);
Where a manual read is unavoidable, separate the phases with requestAnimationFrame: measure in one callback, mutate in the next. The browser then has a clean boundary between the reads and the writes.

Finding it
The browser tells you, in two places, and both are easy to overlook.
The console warns. Chrome prints a violation message naming the duration when a forced reflow takes long enough to matter. It appears as a warning rather than an error, so it is routinely filtered out of view.
[Violation] Forced reflow while executing JavaScript took 63ms
The performance profile marks it. Record an interaction, then look for layout entries with a warning marker inside a scripting task. The profile also names the call site, which the console message does not.
// Crude but effective when you suspect a specific function
const t0 = performance.now();
suspectFunction();
console.log('blocked for', performance.now() - t0, 'ms');
// Confirm the cause: if removing only the reads collapses the time,
// it was layout rather than the work itself.
Test on a throttled CPU. Forced layout costs roughly in proportion to processor speed and document complexity, so a loop that is invisible on a development machine can drop several frames on a mid-range phone — which is where the users who notice actually are.
Where it hides
- Scroll handlers that measure position on every event. This is the classic, and the one
IntersectionObserverwas designed to replace. - Animation loops that read a current value to compute the next one.
- Layout libraries — masonry grids, autosizing textareas, sticky headers, equal-height columns. All of them measure and set, and older implementations do it per element.
- Third-party scripts. Widgets, chat bubbles and consent banners measure the page to place themselves, and you did not write that loop.
- Framework effects that measure right after a render, which is exactly when the pending changes are largest.
The third-party case is the one you cannot batch your way out of. If a profile shows the reflow inside somebody else's script, the options are loading it differently, isolating it, or removing it — territory covered in the wider discussion of third-party cost.
Why it shows up as an interaction problem
A forced reflow inside an event handler delays the paint that would have acknowledged the interaction. The click registered, the handler ran, and the screen did not change until layout finished — which is precisely what responsiveness metrics measure.
That makes this a specific, findable cause behind a poor Interaction to Next Paint score, and one that is usually cheaper to fix than the alternatives. Reordering a loop is a smaller change than reducing bundle size or restructuring rendering.

A fix that holds
- Profile before changing anything — confirm layout is the cost, not the work.
- Find the read inside the loop. There is almost always exactly one.
- Hoist every read out, collect the values, then write.
- Replace scroll measurement with
IntersectionObserverwhere the goal is visibility. - Swap
innerTextfortextContentanywhere rendered text is not what you actually need. - Re-profile on a throttled CPU, because that is where the change is visible.
Step two matters more than it sounds. The instinct is to rewrite the whole function; usually one line moved above the loop does all the work, and a small change is one you can verify.
How to check your page
Use the speed check for the rendering and responsiveness numbers that this affects — the ones that move are interaction and layout related, not transfer size. The header checker is worth a glance when third-party scripts are implicated, since it shows what the page pulls in besides your own code.
This is a regression that returns: a library update, a new widget or a longer list can reintroduce it without anyone editing the loop you fixed. Scheduled monitoring records the measurement over time, which is how the reappearance gets noticed rather than reported months later as "the page feels slow again".
For the wider question of which part of a page load is slow at all, see the TTFB breakdown; for skipping rendering work entirely on long pages, the content-visibility guide.

Frequently asked questions
Is a forced reflow always a problem?
No. One is normal and cheap. The problem is a loop that forces one per iteration, or a single reflow on a document complex enough that recalculating it takes tens of milliseconds.
Why is my code slow only on some pages?
Because the cost is the document's, not the loop's. The same code forces the same number of recalculations; on a heavier page each one costs more. That is why a component can be fine in isolation and janky in the real layout.
Does requestAnimationFrame fix it?
It gives you a clean boundary to separate reads from writes, which is what fixes it. Wrapping the same interleaved loop in a frame callback changes nothing — the ordering has to change too.
How do I know if a third-party script is responsible?
The performance profile names the script for the task containing the layout entry. If it is not yours, batching your own code will not help; the choice is how that script is loaded, or whether it is.
Is getBoundingClientRect() worse than offsetHeight?
Both force layout when changes are pending. The difference is what they return, not what they cost. Call either once and reuse the value rather than choosing between them.
Can CSS containment help?
Yes — containment limits how far a recalculation has to spread, which makes each forced layout cheaper. It reduces the cost rather than the count, so it complements batching instead of replacing it.
Checklist
- Remember the read is what costs, not the write.
- Look for a geometry read inside a loop that also writes.
- Hoist reads out, collect values, then write.
- Use
IntersectionObserverinstead of measuring on scroll. - Use
ResizeObserverinstead of polling dimensions. - Prefer
textContentoverinnerTextwhere rendering is irrelevant. - Separate read and write phases with
requestAnimationFramewhen a manual read is unavoidable. - Enable console warnings — the violation message is a warning and is often filtered.
- Profile on a throttled CPU, not on a development machine.
- Re-measure after library updates; this regression comes back.