Finding Forced Reflow and Layout Thrashing

A list update or resize handler takes 80 ms and the flame chart is striped with purple Layout slices carrying a red “Forced reflow” warning — this guide from Performance Panel Flame Graph Analysis, part of Browser DevTools & Performance Profiling Workflows, shows how to find the exact line causing it and restructure the code so layout runs once per frame.

Symptom Root Cause Immediate Action Measurable Impact
Many small Layout slices inside one task JavaScript reads geometry after each style write Click a slice and follow Layout Forced to the source line Pinpoints the read that forces layout
Red triangle “Forced reflow is a likely performance bottleneck” Synchronous layout during script execution Batch all reads first, then all writes Layout count per task falls from N to 1
Handler time scales with number of items Read/write interleaved inside a loop Move reads out of the loop into an array Task time drops 5–20× on long lists
Layout slices are few but each is slow Very large DOM or expensive CSS invalidation Reduce DOM size, use contain: layout Per-layout cost falls proportionally
Thrashing during scroll or resize Handler reads and writes on every event Coalesce into one requestAnimationFrame callback One layout per frame instead of per event

Root Cause: Reading Geometry Makes the Browser Lay Out Now

Browsers normally batch rendering work. When your code changes a style — el.style.width = '300px', adding a class, inserting a node — the engine marks the affected part of the render tree as dirty and carries on running JavaScript. Style recalculation and layout happen later, once, just before the next frame is painted. That laziness is what keeps DOM manipulation cheap.

The laziness breaks as soon as JavaScript asks for a geometric value while the tree is dirty. Properties and methods such as offsetWidth, offsetTop, clientHeight, scrollTop, getBoundingClientRect(), getComputedStyle() values that depend on layout, and innerText cannot be answered from stale data, so the engine must run style recalculation and layout synchronously, in the middle of your script, before returning the value. That is a forced reflow. One forced reflow is often acceptable; the problem is a loop that writes a style, reads a geometry value, writes again, reads again. Every read after a write forces a fresh layout, so a loop over 500 rows can run layout 500 times within one task. This is layout thrashing.

The Performance panel records each of these as a Layout (and often Recalculate Style) slice nested inside your script, and marks them with a warning because it knows they were forced. Clicking one shows a Layout Forced stack in the Summary tab — the exact line that asked for geometry. Thrashing also has a memory angle: each layout pass allocates and updates layout objects for the dirty subtree, and very large DOMs, including those inflated by detached DOM nodes that were never cleaned up and re-inserted copies, make every pass more expensive.

The cure is structural rather than micro-optimisation: do all reads first, then all writes, so layout happens at most once; or defer writes into a single requestAnimationFrame callback. The general approach to reading these slices in context is covered in how to use the Performance tab to find main-thread jank.

Interleaved versus batched reads and writes The top row shows an interleaved loop: write, read forces layout, write, read forces layout, repeated, producing many layout slices within one task. The bottom row shows a batched loop: all reads first using one layout, then all writes, with a single layout later in the frame. Interleaved (thrashing): layout inside every iteration write layout write layout write layout write layout × 500 rows ≈ 85 ms Batched: read everything, then write everything layout read × 500 (no new layout) write × 500 (just dirties) layout 2 layouts ≈ 6 ms Reads: offsetWidth, getBoundingClientRect(), scrollTop, getComputedStyle() Writes: style changes, class changes, DOM insertion or removal

Step-by-Step Fix

  1. Record the slow interaction with CPU throttling. In DevTools → Performance, set CPU: 4× slowdown in the capture settings, record the interaction, and stop. Verification: the task containing the interaction is visible and marked as a long task if it exceeds 50 ms.
  2. Look for purple Layout slices with red corners. Zoom into the long task. Forced layouts are drawn as Layout slices nested under your script with a red triangle. Verification: hovering one shows “Forced reflow is a likely performance bottleneck”.
  3. Open the Layout Forced stack. Click a forced Layout slice and read the Summary tab: it lists the node count that needed layout, the layout root and a Layout Forced call stack. Verification: the stack’s top frame is a line in your code that reads a geometric property, such as el.offsetHeight.
  4. Find the write that preceded it. Look at the loop or function around that line and identify the style or DOM write that happened just before the read. Verification: you can describe the interleaving, for example “set row.style.height, then read row.offsetTop for the next row”.
  5. Separate reads from writes. Rewrite the code so all geometry is read in one pass and stored in an array, then all writes happen in a second pass — or move the writes into a single requestAnimationFrame callback. Verification: the code contains no geometry read after a write within the same pass.
  6. Re-record and count layouts. Record the same interaction. Verification: the task contains one or two Layout slices instead of hundreds, no forced-reflow warnings remain, and the task duration falls below 50 ms.
Layout count and task time, before and after batching Before batching, updating 500 rows forces 500 layouts and the task takes 85 milliseconds at four times CPU slowdown. After batching reads before writes, the task performs 2 layouts and takes 6 milliseconds. 500-row update at 4× CPU slowdown Layouts per task 500 before 2 after Task duration 85 ms before 6 ms after one layout pass is unavoidable; the goal is one per frame, not one per row

Command and Code Reference

Use case: the classic thrashing loop and its batched rewrite. Equalising card heights reads each card’s height after setting the previous card’s height, forcing layout on every iteration.

// Thrashing: write (style.height) followed by read (offsetHeight) each iteration
function equaliseThrashing(cards) {
  let tallest = 0;
  for (const card of cards) {
    card.style.height = 'auto';                 // write → layout now dirty
    tallest = Math.max(tallest, card.offsetHeight); // read → forced layout
  }
  for (const card of cards) card.style.height = `${tallest}px`;
}

// Batched: one write pass to reset, one read pass, one write pass
function equaliseBatched(cards) {
  for (const card of cards) card.style.height = 'auto';        // writes only
  const heights = cards.map((card) => card.offsetHeight);       // one layout, then cached reads
  const tallest = Math.max(...heights);
  for (const card of cards) card.style.height = `${tallest}px`; // writes only
}

Use case: coalesce a scroll or resize handler into one frame. Events can fire many times per frame; reading and writing in each call multiplies layout work.

// Read in the event, write once per frame in requestAnimationFrame
let pending = false;
let lastScrollY = 0;

window.addEventListener('scroll', () => {
  lastScrollY = window.scrollY;       // cheap read; no writes here
  if (pending) return;
  pending = true;
  requestAnimationFrame(() => {
    pending = false;
    header.classList.toggle('compact', lastScrollY > 80); // single write per frame
  });
}, { passive: true });

Use case: detect forced layouts in development. A quick console snippet highlights the worst offenders while you click around, without a full recording.

// Paste in the Console: logs long tasks so you know where to record next
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) console.warn(`Long task ${entry.duration.toFixed(0)} ms`, entry);
  }
}).observe({ type: 'longtask', buffered: true });

Verification and Regression Prevention

A thrashing fix is verified when the interaction’s task contains at most one Recalculate Style and one Layout slice caused by your code, no slice carries the forced-reflow warning, and the task time at 4× CPU slowdown is under 50 ms for your largest realistic data set. Test with the largest list you support, not a demo list of ten items: thrashing cost scales linearly with item count, so a fix that looks unnecessary at 10 items matters enormously at 1,000.

For prevention, adopt a convention that component code never reads layout inside render loops, and route unavoidable measurements through a small utility that batches reads before writes (libraries such as FastDOM implement this pattern). Add a Long Animation Frames or long-task observer to your real-user monitoring so production interactions that exceed 50 ms are reported with their script attribution — covered in attributing jank with Long Animation Frames. Keep DOM size in check as well: fewer nodes means every unavoidable layout is cheaper.

A verified layout-thrashing fix The interaction’s task should contain at most one Recalculate Style and one Layout slice caused by your code, no slice should carry the forced reflow warning, and the task should take under 50 ms at 4× CPU slowdown with the largest realistic data set. Interaction task, largest supported list One style + one layout At most one Recalculate Style and one Layout from your code. No forced-reflow flag No slice carries the purple forced reflow warning. Under 50 ms at 4× Task time with CPU slowdown on 1,000 items, not 10.

Frequently Asked Questions

Which properties force a synchronous layout?

Anything that needs up-to-date geometry or computed layout: offsetTop/Left/Width/Height, clientTop/Left/Width/Height, scrollTop/Left/Width/Height, getBoundingClientRect(), getClientRects(), innerText, focus() in some cases, scrollIntoView(), and getComputedStyle() for layout-dependent values. Reading them is only expensive when styles or DOM have changed since the last layout.

Is one forced reflow always bad?

No. Measuring once after a batch of writes is often the simplest correct approach and costs a single layout. The problem is repeated forced layouts within one task, where each read after a write repeats work the browser would otherwise do once per frame.

Does CSS containment help with thrashing?

It reduces the cost of each layout by limiting how much of the page must be recomputed — contain: layout or content-visibility: auto on independent sections keeps invalidation local. It does not remove thrashing; the loop still forces layout repeatedly, just over a smaller subtree. Fix the read/write order first, then use containment to make the remaining layouts cheaper.