← Debugging Guides

阅读中文版本

Layout Thrashing — Scroll Handlers Force Synchronous Reflows

Eliminate layout thrashing where JavaScript interleaves layout reads and writes, measure reflows with DevTools, and batch with requestAnimationFrame or transforms.

intermediatedebuggingperformancelayoutjavascript

Symptoms

  • Scrolling or resizing makes the page janky; the Performance panel shows long purple Layout bars per frame
  • Console warnings: Forced reflow while executing JavaScript took 42ms
  • A loop reads offsetHeight, writes styles, then reads again — each iteration pays a full reflow

Possible causes

  • Read-after-write patterns: geometry reads (offsetHeight, getBoundingClientRect) interleaved with style writes invalidate the layout each time
  • Scroll and resize handlers doing layout reads on every event, dozens of times per second
  • Animations implemented by writing top/left instead of transforms, forcing layout on every frame
  • Framework code reading DOM measurements inside a render loop that also mutates styles

Diagnosis steps

Record and read the flame chart

DevTools → Performance → Record → scroll the page → stop

Interpretation

Repeated purple Layout blocks with tiny script slices between them is the visual signature of read-write interleaving.

Enable reflow logging

DevTools → Console → settings → "Log XMLHTTP requests"/"Show forced reflow warnings"; or instrument getBoundingClientRect reads

Interpretation

Forced reflow warnings name the script line that triggered the layout after a write — the exact loop to restructure.

Count reads per event

let n = 0; const orig = Element.prototype.getBoundingClientRect; Element.prototype.getBoundingClientRect = function (...a) { n++; return orig.apply(this, a); };

Interpretation

Dozens of reads per scroll event quantifies the amplification a single event handler creates.

Root cause

The browser caches layout and recalculates it lazily. A style write invalidates the cache; the next geometry read forces a synchronous recalculation. Interleaving reads and writes therefore pays one full layout per cycle, and doing that per scroll event turns a cached-millisecond read into frame-breaking work.

Fix

Batch all reads, then all writes (read phase, write phase), move scroll handlers to requestAnimationFrame, animate with transform and opacity which skip layout, and cache measurements when the underlying inputs have not changed.

Risk note

position: fixed emulation with transforms changes containing-block semantics. Verify overlays and stacking after the migration.

Verify the fix

Re-record the Performance trace during scroll: layout bars shrink to occasional small ones, forced reflow warnings disappear, and frame rate holds the display budget.

Symptom walkthrough

The Performance trace shows the crime scene:

[script 0.4ms] [Layout 18ms] [script 0.3ms] [Layout 17ms] ...

Long purple Layout bars interleaved with tiny script slices, repeated per scroll event. The console adds the confession when warned: “Forced reflow while executing JavaScript took 42ms”, pointing at the reading line. The classic source shape:

items.forEach((el) => {
  const top = el.offsetTop;        // read -> forces layout after...
  el.style.transform = `translateY(${top}px)`; // ...this write invalidated it
});

One layout per iteration, times hundreds of elements, per scroll event.

Fix in practice

Phase the work — reads first, then writes:

function update() {
  // read phase: no writes in between
  const tops = items.map((el) => el.offsetTop);

  // write phase: no reads in between
  items.forEach((el, i) => {
    el.style.transform = `translateY(${tops[i]}px)`;
  });
}

Throttle event-driven work to the frame budget:

let queued = false;
window.addEventListener('scroll', () => {
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => { update(); queued = false; });
}, { passive: true });

And for motion, stay on the compositor path — animate transform and opacity only; top/left/width/height animations pay layout on every frame by definition.

Verification

Re-record a Performance trace while scrolling with the fix deployed: the long purple Layout bars collapse to occasional small ones, and the console no longer prints forced reflow warnings. Re-run the instrumented getBoundingClientRect counter per scroll event — it should show the batched read count once per frame, not dozens per event. Check that frame rate holds the display budget on the slowest device you support, since a 16ms budget on a 60Hz screen leaves no room for even one 18ms layout. Watch for the related regression pattern: a component library upgrade that reintroduces interleaved reads and writes — the reflow warning reappears in the console before users feel the jank.