InterviewsVector
advancedCommon5 min read · Updated Aug 23, 2026

Layout Thrashing and Reflow: Batching DOM Reads and Writes

Why interleaving DOM reads and writes forces synchronous layout and tanks performance — the rendering pipeline, forced reflow, the read/write batching fix, requestAnimationFrame, and compositor-only properties.


The problem

This loop resizes 500 list items to match their content and it janks badly. Why is it slow, and how do you fix it without changing what it does?

for (const item of items) {
  // read, then write, then read, then write...
  item.style.height = item.offsetHeight + 20 + "px";
}

This is layout thrashing, and it's one of the most common real-world frontend performance questions because the fix is invisible: the code looks O(n), but it behaves far worse. Understanding why is the difference between "make it faster" and "I'd add a will-change" (a non-answer).

The one-liner: batch your reads, then batch your writes — never interleave them.

The rendering pipeline

Every visual update flows through a pipeline. Each frame (ideally every ~16 ms for 60fps) the browser may run:

JavaScript → Style → Layout → Paint → Composite

  • Layout (a.k.a. reflow) computes geometry — position and size of every affected box. It's expensive and cascades: changing one element's size can shift its siblings, parents, and children.
  • Paint fills in pixels (colors, text, shadows).
  • Composite stacks the painted layers on the GPU — cheap.

The engine is smart: it batches DOM writes and recalculates layout once, right before painting. You get that batching for free… until you break it.

Why the loop is slow: forced synchronous layout

Reading a geometric property — offsetHeight, offsetTop, getBoundingClientRect(), getComputedStyle(), scrollTop, clientWidth — must return an up-to-date value. If there are pending writes queued, the browser cannot hand you a stale number, so it stops and runs layout right now to answer you. That's a forced synchronous layout (or "forced reflow").

In the loop above, each iteration:

  1. writes item.style.height → dirties the layout,
  2. next iteration reads offsetHeight → forces a full synchronous reflow to flush the dirty state.

So instead of one batched layout at the end, you trigger a layout on every single iteration — and because layout cost scales with the DOM, you've turned a linear loop into something closer to O(n²) in practice. That's layout thrashing: the read/write/read/write ping-pong that defeats the browser's batching.

The fix: separate the reads from the writes

Same behavior, one reflow instead of 500 — measure everything first, then mutate everything:

// PHASE 1 — read every measurement up front (one layout flush total)
const heights = items.map((item) => item.offsetHeight);
 
// PHASE 2 — now only write; nothing reads geometry, so nothing forces layout
items.forEach((item, i) => {
  item.style.height = heights[i] + 20 + "px";
});

Reads no longer sit after writes, so the browser never has to flush mid-loop. It coalesces all the writes and lays out once before the next paint. This read-then-write discipline is the whole idea behind the FastDOM pattern and libraries like it.

Predict the outputWhich version avoids forced synchronous layout?
// A
boxes.forEach(b => {
const w = b.offsetWidth;
b.style.width = w * 2 + "px";
});

// B
const widths = boxes.map(b => b.offsetWidth);
boxes.forEach((b, i) => { b.style.width = widths[i] * 2 + "px"; });

Scheduling writes with requestAnimationFrame

requestAnimationFrame(cb) runs cb right before the next paint — the correct place to make visual changes, because they'll be picked up in that frame's single layout pass instead of forcing an extra one. Pair it with the read/write split: measure now, mutate in the rAF callback.

const measurements = elements.map((el) => el.getBoundingClientRect());
requestAnimationFrame(() => {
  elements.forEach((el, i) => animateInto(el, measurements[i]));
});

(This is exactly the read-then-write technique the FLIP animation pattern uses to animate layout changes smoothly.)

Other ways to keep layout cheap

  • Mutate off-document. Build a subtree in a DocumentFragment and append it once, or set display:none on a container, do bulk edits, then reveal — a hidden element isn't laid out, so edits don't reflow.
  • Prefer compositor-only properties. transform and opacity can be handled on the GPU without layout or paint. Animating left/top/width/margin reflows every frame; animating transform: translate() doesn't. This is the single biggest win for smooth animation.
  • Virtualize long lists. Don't put 10,000 rows in the DOM — render only what's visible. That's the core of the Infinite Scroll Controller, and it keeps layout cost bounded no matter how much data loads.
  • content-visibility: auto lets the browser skip layout and paint for off-screen subtrees entirely — near-free virtualization for long static pages.
  • Debounce layout-reading handlers. scroll and resize fire rapidly; reading geometry in them is thrashing on a timer. Throttle them, or use IntersectionObserver / ResizeObserver, which report geometry asynchronously without you forcing a synchronous read.

Interview follow-ups to be ready for

  • "Which properties force layout when read?" The geometric ones: offsetTop/Left/Width/Height, client*, scroll*, getBoundingClientRect(), getComputedStyle() for computed sizes, scrollTo/focus in some cases. Reading any of them flushes pending style/layout.
  • "Why is transform faster than top?" top changes geometry → layout + paint + composite. transform can be composited on its own GPU layer → composite only, no layout or paint.
  • "Is will-change the fix?" It's a hint that promotes an element to its own layer ahead of time, useful for animations — but it costs memory and, overused, hurts more than it helps. It doesn't fix thrashing; batching reads/writes does.
  • "How would you find a forced reflow?" DevTools Performance panel flags them with a purple "Layout" bar and a warning triangle; the profiler shows the stack that triggered it.

  • A hands-on exercise: profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, then chunk off the main thread — in that order.

  • How GC reachability actually works, the four classic leaks, WeakMap/WeakRef, and finding retained memory with heap snapshots.

  • Big-O reasoning the way interviewers want it: the Map/Set/object/array cheat sheet, hidden O(n) costs like spread and includes, and trading memory for speed.

  • performance.now vs Date.now, the User Timing API, PerformanceObserver, DevTools flame charts, and the micro-benchmark traps that make numbers lie.