Skip to content
RU
← All articles

Forced Reflow: When Reading the DOM Costs You a Frame

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.

Many pending changes collapsing into a single deferred recalculation, beside the same changes each triggering their own immediate recalculation
Deferred, the changes cost one recalculation. Interleaved with reads, each one costs its own.

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

CategoryExamples
Box geometryoffsetWidth, offsetHeight, offsetTop, offsetLeft
Client boxclientWidth, clientHeight, clientTop, clientLeft
ScrollingscrollTop, scrollLeft, scrollWidth, scrollHeight
Explicit measurementgetBoundingClientRect(), getClientRects()
Computed stylegetComputedStyle() for layout-dependent properties
Side effectsfocus(), 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:

  • ResizeObserver reports an element's size when it changes, with the dimensions supplied in the callback. No property read, no forced layout.
  • IntersectionObserver reports 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.

Interleaved read and write operations reordered into one block of reads followed by one block of writes
The work is identical. Grouping the reads leaves one recalculation instead of one per iteration.

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 IntersectionObserver was 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.

An interaction followed by a long blocking segment before the screen updates, compared with a short one
The interaction is not slow because the work is large. It is slow because the paint waits behind a synchronous recalculation.

A fix that holds

  1. Profile before changing anything — confirm layout is the cost, not the work.
  2. Find the read inside the loop. There is almost always exactly one.
  3. Hoist every read out, collect the values, then write.
  4. Replace scroll measurement with IntersectionObserver where the goal is visibility.
  5. Swap innerText for textContent anywhere rendered text is not what you actually need.
  6. 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.

A responsiveness measurement improving after a fix and drifting back up after a later change
Fixed once is not fixed. A longer list or a new widget reintroduces the same cost without touching the code that was repaired.

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 IntersectionObserver instead of measuring on scroll.
  • Use ResizeObserver instead of polling dimensions.
  • Prefer textContent over innerText where rendering is irrelevant.
  • Separate read and write phases with requestAnimationFrame when 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.

Check your website right now

Check your site's speed →
More articles: Performance
Performance
Gzip vs Brotli: Web Compression Compared
16.03.2026 · 700 views
Performance
CDN Cache Invalidation: Strategies for Fresh Content
16.03.2026 · 680 views
Performance
Resource Hints: Prefetch, Preload, Preconnect, DNS-Prefetch
16.03.2026 · 536 views
Performance
Latency vs Throughput: Network Performance Metrics
16.03.2026 · 529 views