Skip to content

Layout Thrashing and Forced Reflow

advanced10 min read

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.

Mental Model

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
Execution Trace
Thrashed
Read offsetHeight → FORCE LAYOUT (1st)
Browser recalculates all element positions
Thrashed
Write style.height → invalidate layout
Layout is now dirty
Thrashed
Read offsetHeight → FORCE LAYOUT (2nd)
Layout dirty from previous write, recalculate again
Thrashed
Write style.height → invalidate layout
Layout dirty again
Thrashed
...repeat 98 more times...
100 forced layouts total = 100-400ms
Batched
Read all offsetHeight values → FORCE LAYOUT (once)
One layout calculation for all reads
Batched
Write all style.height values → invalidate layout
Layout dirty, but no reads follow
Batched
Browser performs layout at next frame
1 normal async layout = 1-4ms

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';
    });
  });
}
Common Trap

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:

  1. Used CSS aspect-ratio: 4/3 instead of JavaScript height calculation — zero JS, zero layout
  2. For browsers without aspect-ratio support, used the padding-top percentage trick
  3. Images that needed dynamic sizing used ResizeObserver instead 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 doWhat 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
Quiz
What happens when you read element.offsetWidth after writing element.style.width = '200px' in the same synchronous block?
Quiz
You need to match 50 elements' heights to the tallest one. Which approach avoids layout thrashing?
Key Rules
  1. 1Reading layout properties (offsetHeight, getBoundingClientRect, getComputedStyle) after writing styles forces synchronous reflow.
  2. 2Layout thrashing = interleaving reads and writes in a loop. N interleaved operations = N forced layouts.
  3. 3Batch all DOM reads first, then all DOM writes. Never interleave.
  4. 4Use transform instead of top/left/width/height for animations — transform is compositor-only.
  5. 5requestAnimationFrame ensures correct timing but does not prevent thrashing — separate reads from writes inside the callback.
  6. 6Prefer CSS solutions (aspect-ratio, flexbox, grid) over JavaScript-based sizing to avoid forced reflows entirely.