TL;DR / Reading layout properties (like
offsetHeight) after writing style changes forces the browser to recalculate layout synchronously — doing this in a loop creates devastating performance problems.
How It Works
BAD: interleaved read/write GOOD: batched reads then writes
┌─────────────┐ ┌─────────────┐
│ Read height │ │ Read height │ 1 layout (if needed)
└─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Write width │ invalidates layout │ Read height │
└─────────────┘ └─────────────┘
┏━━━━━━━━━━━━━┓ ┌─────────────┐
┃ Read height ┃ <- FORCED layout! │ Write width │ batched, 0 forced
┗━━━━━━━━━━━━━┛ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ Write width │ invalidates layout │ Write width │
└─────────────┘ └─────────────┘
┏━━━━━━━━━━━━━┓
┃ Read height ┃ <- FORCED layout! = 1 layout total
┗━━━━━━━━━━━━━┛
= N forced layouts per loop
The browser maintains a rendering pipeline: style changes are batched and layout is recalculated lazily, typically once per frame before painting. But when JavaScript reads a layout property after modifying styles, the browser must compute layout immediately to return an accurate value. This synchronous, forced layout is called a "forced reflow," and triggering it repeatedly is layout thrashing.
The Invalidation-Read Cycle
When you modify a style property — element.style.width = '100px' — the browser marks the layout as "dirty" but does not immediately recalculate. It defers the work, expecting to batch all changes and compute once. But if the very next line reads a layout-dependent property — element.offsetHeight — the browser must calculate layout right now to return the correct value.
The cost of one forced layout is typically small: a few milliseconds for a moderately complex page. The disaster happens in loops:
const elements = document.querySelectorAll('.item');
elements.forEach(el => {
const height = el.offsetHeight; // READ — forces layout if dirty
el.style.width = height + 'px'; // WRITE — invalidates layout
});
Each iteration reads, forcing a layout, then writes, invalidating layout for the next iteration. For 100 elements, this causes 100 full layout calculations instead of one. At 5ms per layout on a complex page, that is 500ms of main thread blocking — enough to drop 30 frames.
Properties That Force Layout
The most common layout-triggering reads include:
- Dimensions:
offsetWidth,offsetHeight,clientWidth,clientHeight,scrollWidth,scrollHeight - Position:
offsetTop,offsetLeft,getBoundingClientRect() - Scroll:
scrollTop,scrollLeft - Computed styles:
getComputedStyle()when layout-dependent properties are queried - Focus:
focus()may trigger layout to determine scroll position
Writing properties that trigger layout invalidation includes any modification to geometric CSS properties: width, height, padding, margin, border, top, left, font-size, display, and many more.
The Fix: Batch Reads and Writes
The solution is to separate reads from writes. Read all values first, then perform all writes:
const elements = document.querySelectorAll('.item');
// Phase 1: Read everything
const heights = Array.from(elements).map(el => el.offsetHeight);
// Phase 2: Write everything
elements.forEach((el, i) => {
el.style.width = heights[i] + 'px';
});
This triggers at most one layout (during the read phase) because no writes have invalidated layout yet. All subsequent writes batch and are processed once before the next paint.
FastDOM and requestAnimationFrame
The fastdom library automates read/write batching by scheduling all reads before writes within a single requestAnimationFrame callback. You enqueue reads and writes separately, and fastdom ensures proper ordering:
fastdom.measure(() => {
const height = element.offsetHeight;
fastdom.mutate(() => {
element.style.width = height + 'px';
});
});
Without a library, you can achieve the same by collecting all reads, then using requestAnimationFrame for writes. The key is never interleaving: reads go in one phase, writes in the next.
Virtual DOM and Layout Thrashing
React's virtual DOM naturally avoids layout thrashing for most operations because it batches DOM mutations. React calculates what changed in memory, then applies all DOM writes in a single commit phase. No DOM reads happen between writes during commit.
However, layout thrashing can still occur in useLayoutEffect or componentDidMount if you read and write DOM properties in a loop. These hooks run synchronously after DOM mutations but before paint — the same timing window where layout thrashing is most dangerous.
Gotchas
getComputedStyledoes not always force layout. It only forces layout when you query a property that depends on layout (likewidth). Querying color-only properties may not trigger reflow.- CSS containment can reduce layout scope. With
contain: layout, a forced layout inside a contained element only recalculates that subtree, not the entire page. This turns an O(n) operation into O(subtree). ResizeObserveris the modern alternative to reading dimensions in loops. It batches notifications before paint, avoiding forced layouts entirely.display: noneelements do not participate in layout. Reading their dimensions returns zero and does not force layout. But toggling them todisplay: blockdoes force layout for all subsequent reads.- Third-party libraries are frequent offenders. Animation libraries, carousel plugins, and tooltip positioners often read and write in loops internally. Audit your dependencies with the Performance tab.