Layout Thrashing: What It Is and How to Fix It

Performance

A 60fps UI gives the browser 16.67 milliseconds per frame. To hit that budget consistently, the browser needs to complete JavaScript execution, style recalculation, layout, paint, and compositing within that window. Layout thrashing is when JavaScript forces the browser to perform layout repeatedly within a single frame, consuming the entire budget on a single type of work.

What Is Layout Thrashing?

The browser calculates layout (dimensions and positions of elements) lazily. After you mutate the DOM or change styles that affect geometry, the browser marks layout as "dirty." It doesn't recalculate immediately — it waits until the end of the frame.

But if you then read a layout property (like offsetWidth, clientHeight, scrollTop) from JavaScript, the browser has no choice. It must recalculate layout synchronously to give you an accurate answer. This is called a forced synchronous layout.

Thrashing happens when you alternate reads and writes in a loop:

// ❌ Layout thrashing
const elements = document.querySelectorAll('.box')
elements.forEach(el => {
  const width = el.offsetWidth  // READ  → forces layout
  el.style.width = width * 2 + 'px'  // WRITE → invalidates layout
})

Each iteration reads (forces layout), writes (invalidates layout), reads again (forces layout again). For 100 elements, that's 100 forced layout recalculations in one frame.

The Layout-Invalidating Properties

Not all DOM reads trigger a forced layout. Only reading geometric properties does it when the layout is dirty:

Reads that force layout:

  • element.offsetWidth / offsetHeight / offsetTop / offsetLeft
  • element.clientWidth / clientHeight
  • element.scrollWidth / scrollHeight / scrollTop / scrollLeft
  • element.getBoundingClientRect()
  • window.innerWidth / innerHeight
  • getComputedStyle(element)

Writes that invalidate layout:

  • element.style.width, height, top, left, margin, padding, border
  • Adding/removing CSS classes that affect geometry
  • Inserting or removing DOM nodes

The Fix: Batch Reads Before Writes

Separate all reads from all writes. Do every read first, store the values, then do every write:

// ✅ Batch reads, then writes
const elements = document.querySelectorAll('.box')

// All reads first
const widths = Array.from(elements).map(el => el.offsetWidth)

// Then all writes
elements.forEach((el, i) => {
  el.style.width = widths[i] * 2 + 'px'
})

One forced layout at the start of the reads (if layout is dirty), then zero forced layouts during writes.

For complex scheduling across different parts of the codebase, libraries like FastDOM formalise this pattern:

import fastdom from 'fastdom'

elements.forEach(el => {
  fastdom.measure(() => {
    const width = el.offsetWidth
    fastdom.mutate(() => {
      el.style.width = width * 2 + 'px'
    })
  })
})

FastDOM queues all measures for the start of the frame and all mutations after them, preventing accidental interleaving.

requestAnimationFrame for Animations

If you're animating, do your DOM reads and writes inside requestAnimationFrame. The browser guarantees callbacks run just before the frame renders, giving you a fresh layout state and a full frame budget:

function animate() {
  requestAnimationFrame(() => {
    // Read layout state at the top of the frame
    const rect = el.getBoundingClientRect()

    // Write changes
    el.style.transform = `translateX(${rect.left + 1}px)`

    animate() // schedule next frame
  })
}

Avoid setTimeout(fn, 0) for animations — it doesn't sync with the frame boundary and can run multiple times in one frame or skip frames entirely.

Detecting Layout Thrashing

Open Chrome DevTools → Performance tab → record a scroll or animation. In the flame chart, look for:

  • "Forced reflow" warnings in the summary (a yellow triangle on a Layout task)
  • Alternating "Recalculate Style/Layout" blocks inside a single JavaScript call

Another approach: performance.mark() around suspected code to measure real-world impact.

CSS-Only Animations Sidestep the Problem

If you're animating properties the browser can handle on the compositor thread — transform and opacity — layout is never involved:

/* ✅ Compositor-only — no layout, no paint */
.box {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* ❌ Triggers layout on every frame */
.box {
  transition: width 0.3s ease, height 0.3s ease;
}

transform and opacity bypass layout and paint entirely, running on the GPU. This is why "animate transform, not position" is one of the most-repeated CSS performance rules.

Conclusion

Layout thrashing is caused by interleaving DOM reads and writes, which forces the browser to synchronously recalculate layout multiple times per frame. The fix is always the same: batch all reads first, then all writes. For animations, use requestAnimationFrame to synchronise with the frame boundary, and prefer transform and opacity over properties that trigger layout. A few lines of restructuring can take a janky interaction from 10fps to 60fps.