Layout Thrashing and Forced Reflow
The Performance Bug Hiding in Plain Sight
Layout thrashing is responsible for more production jank bugs than any other single cause. It happens when JavaScript repeatedly reads layout properties and writes style changes in an interleaved pattern, forcing the browser to recalculate layout synchronously — potentially hundreds of times in a single frame.
The insidious part: the code looks completely normal. There's no red flag. No lint warning. Just a loop that reads offsetHeight and writes style.height, and suddenly your page drops to 5fps.
Imagine you're reorganizing a warehouse. You move a box (write), then check where another box ended up (read) — but to answer that, someone has to walk the entire warehouse and measure everything first (layout). Then you move another box (write) and ask again (read) — another full warehouse walk. Repeat 100 times. That's layout thrashing: forcing the browser to walk the entire layout tree between every tiny change, instead of making all changes first and measuring once at the end.
What Triggers Forced (Synchronous) Reflow
When you read any of these layout properties from JavaScript, the browser must ensure the layout is up-to-date. If any style changes are pending, it forces a synchronous layout recalculation before returning the value:
// ALL of these force synchronous layout if styles have changed:
// Element geometry
element.offsetTop element.offsetLeft
element.offsetWidth element.offsetHeight
element.clientTop element.clientLeft
element.clientWidth element.clientHeight
element.scrollTop element.scrollLeft
element.scrollWidth element.scrollHeight
// Computed styles
window.getComputedStyle(element)
element.getBoundingClientRect()
// Scroll-related
element.scrollIntoView()
element.focus() // may trigger scroll + layout
// Window
window.innerHeight window.innerWidth
window.scrollX window.scrollY
The browser normally batches style changes and recalculates layout once per frame (during the rendering pipeline). But when you read a layout property, the browser must recalculate immediately — you've asked a question that requires an up-to-date answer.
The Read-Write-Read-Write Antipattern
This is the classic layout thrashing pattern:
// BAD: Layout thrashing — forces N synchronous layouts
const elements = document.querySelectorAll('.card');
for (const el of elements) {
// READ: forces layout to get current height
const height = el.offsetHeight;
// WRITE: invalidates layout (style change pending)
el.style.height = height + 10 + 'px';
// Next iteration: READ again → forces ANOTHER synchronous layout
// because the previous WRITE invalidated it
}
// With 100 cards, this forces 100 synchronous layouts in one frame
Each iteration reads (forcing layout), then writes (invalidating layout), then the next iteration reads (forcing layout again). With N elements, you get N forced layouts instead of the 1 layout the browser would normally do.
The Fix: Batch Reads, Then Batch Writes
// GOOD: Read all values first, then write all values
const elements = document.querySelectorAll('.card');
// Phase 1: Read everything (one layout calculation for all reads)
const heights = [];
for (const el of elements) {
heights.push(el.offsetHeight);
}
// Phase 2: Write everything (no reads to force layout)
for (let i = 0; i < elements.length; i++) {
elements[i].style.height = heights[i] + 10 + 'px';
}
// Result: 1 forced layout (first read) + 1 normal layout (browser's frame)
// Instead of: 100 forced layouts
requestAnimationFrame for DOM Batching
requestAnimationFrame (rAF) schedules a callback to run just before the browser's next paint. This is the ideal time to batch DOM writes:
// Pattern: Read now, write in rAF
function resizeCards() {
const elements = document.querySelectorAll('.card');
// Read in the current frame (layout is clean)
const heights = Array.from(elements).map(el => el.offsetHeight);
// Write in the next frame's paint callback
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = heights[i] * 1.5 + 'px';
});
});
}
Reading layout properties inside a requestAnimationFrame callback after writing styles in the same callback still causes forced reflow. rAF doesn't magically prevent thrashing — it just ensures your code runs at the right time in the frame. You still need to separate reads from writes.
// STILL BAD — thrashing inside rAF
requestAnimationFrame(() => {
el.style.width = '200px'; // write
const h = el.offsetHeight; // read → forced reflow!
el.style.height = h + 'px'; // write
});
// GOOD — reads before writes inside rAF
requestAnimationFrame(() => {
const h = el.offsetHeight; // read (layout is clean)
el.style.width = '200px'; // write
el.style.height = h + 'px'; // write (no read after, no thrash)
});Use transform Instead of Geometric Properties
The ultimate fix for many layout thrashing scenarios: don't trigger layout at all.
// BAD: Forces reflow on every frame — layout + paint + composite
function animateDown(element) {
let top = 0;
function frame() {
top += 2;
element.style.top = top + 'px'; // Layout trigger every frame
if (top < 200) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// GOOD: Compositor-only — zero reflow, zero repaint
function animateDown(element) {
let offset = 0;
function frame() {
offset += 2;
element.style.transform = `translateY(${offset}px)`; // Composite only
if (offset < 200) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
The transform version is fundamentally different:
- No layout recalculation (the element's flow position doesn't change)
- No repaint (the compositor just moves the existing texture)
- Runs on the compositor thread (doesn't block JavaScript)
The FastDOM Pattern
For complex UIs where you can't easily separate reads and writes, the FastDOM pattern queues all reads and writes and executes them in batches:
// Conceptual FastDOM implementation
const readQueue = [];
const writeQueue = [];
function measureThenMutate(readFn, writeFn) {
readQueue.push(readFn);
writeQueue.push(writeFn);
scheduleFlush();
}
function flush() {
// Execute ALL reads first (one layout calculation)
const results = readQueue.map(fn => fn());
readQueue.length = 0;
// Then execute ALL writes (no interleaved reads)
writeQueue.forEach((fn, i) => fn(results[i]));
writeQueue.length = 0;
}
In practice, use a library like fastdom or ensure your framework handles batching (React batches DOM writes by default through its reconciliation process).
Production Scenario: The Infinite Scroll That Froze
A social media feed had smooth scrolling with 20 posts but froze at 200+ posts. The root cause was in the "auto-height" image layout:
// This ran on every scroll event for every visible image
images.forEach(img => {
const width = img.offsetWidth; // READ → forced layout
img.style.height = (width * 0.75) + 'px'; // WRITE → invalidate
// Next iteration forces layout again
});
With 40 visible images, this forced 40 synchronous layouts per scroll event — at 60fps, that's 2,400 forced layouts per second.
The fix:
- Used CSS
aspect-ratio: 4/3instead of JavaScript height calculation — zero JS, zero layout - For browsers without
aspect-ratiosupport, used the padding-top percentage trick - Images that needed dynamic sizing used
ResizeObserverinstead of scroll-event measuring
/* The CSS-only fix — no JavaScript needed */
.feed-image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
Result: Scroll performance went from 8fps to 60fps with 500+ posts.
How React avoids layout thrashing
React's reconciliation process naturally prevents most layout thrashing. React batches all state updates, computes the new virtual DOM, diffs against the current DOM, and applies all DOM mutations in a single synchronous batch during the commit phase. Since React writes all DOM changes at once (without interleaved reads), you don't get the read-write-read-write pattern. However, you CAN still cause thrashing in useLayoutEffect or in ref callbacks where you read layout properties and then set state — triggering another render with more DOM writes.
| What developers do | What they should do |
|---|---|
| Read offsetHeight inside a loop that also writes styles Each write invalidates layout, and each subsequent read forces a synchronous recalculation — N reads after N writes = N layouts | Read all values first into an array, then write all values |
| Use top/left for JavaScript animations top/left triggers layout every frame. transform is compositor-only — no layout, no paint, runs on GPU | Use transform: translate() for JavaScript animations |
| Calculate element dimensions in JavaScript when CSS can do it CSS-based sizing happens once in the browser's layout pass. JS-based sizing requires reading layout properties, risking thrashing | Use CSS aspect-ratio, flexbox, or grid for sizing when possible |
| Assume requestAnimationFrame prevents layout thrashing rAF runs your code at the right time, but read-write interleaving inside rAF still forces synchronous layouts | rAF ensures correct timing but you still must separate reads from writes inside the callback |
- 1Reading layout properties (offsetHeight, getBoundingClientRect, getComputedStyle) after writing styles forces synchronous reflow.
- 2Layout thrashing = interleaving reads and writes in a loop. N interleaved operations = N forced layouts.
- 3Batch all DOM reads first, then all DOM writes. Never interleave.
- 4Use transform instead of top/left/width/height for animations — transform is compositor-only.
- 5requestAnimationFrame ensures correct timing but does not prevent thrashing — separate reads from writes inside the callback.
- 6Prefer CSS solutions (aspect-ratio, flexbox, grid) over JavaScript-based sizing to avoid forced reflows entirely.