← blog
JavaScriptOctober 19, 2026 · 5 min read

Your 'Expand All' Button Is Quietly Causing Layout Thrashing

An 'expand all' click that stutters isn't a slow CPU — it's layout thrashing, a loop that forces the browser to recalculate layout once per row instead of once per click. Here's how to spot it and the two-line fix.

Parsa Jiravand · Frontend engineer · building bestpractic
Your 'Expand All' Button Is Quietly Causing Layout Thrashing

You click "Expand all" on a settings page or an FAQ list, and for a moment, nothing happens. Not nothing-nothing — the click registered, the network tab is quiet, there's no spinner. The page is just... stuck, for a beat, before every row snaps open at once.

Nobody wrote a slow function on purpose. The code probably looks completely reasonable. That's what makes this one worth knowing: the bug isn't in what the loop does, it's in the order it does it in.

Here's a version of the handler almost everyone writes the first time:

JavaScript
1
2
3
4
5
6
7
function expandAll(rows) { rows.forEach((row) => { const content = row.querySelector(".row-content"); const height = content.scrollHeight; // read content.style.height = height + "px"; // write }); }

Read the current content height, set the element to that height so the height: 0 → height: auto-style expand animation has something to animate toward. Repeat for every row. It's clean, it's obviously correct, and it ships.

Open your browser's performance profiler, click "Expand all" on a list of forty or fifty rows, and record. You'll see a stack of narrow purple bars, one per row, each labeled something like "Forced reflow" or "Forced synchronous layout". That label is the browser telling you exactly what just happened, in its own words.

Browsers don't recompute layout after every style change. They batch it — style and layout writes pile up in a queue, and the engine runs one layout pass right before the next paint. That's why setting ten CSS properties in a row usually costs about the same as setting one.

But some reads break the batch. scrollHeight, offsetHeight, offsetWidth, clientWidth, getBoundingClientRect(), getComputedStyle() — anything that hands you back a real number has to be correct right now, not "correct as of the last paint." If there's a pending style write the browser hasn't applied to layout yet, it can't answer honestly without doing the work first. So it does the work first: it flushes every queued change and recalculates layout, synchronously, on the spot, then hands you the number.

Now look at the loop again. Each iteration writes content.style.height, which invalidates layout. The next iteration reads scrollHeight on a different element — but the browser doesn't know your next read doesn't care about the previous write, so it has to flush and recalculate to be sure. Forty rows means forty forced layouts, each one touching the whole document, not just the row you're looking at. That's layout thrashing: alternating reads and writes that force the same work the browser was trying to batch away, over and over, inside a single click handler, on the main thread, blocking everything else — including the next frame.

You don't need a library for this. You need two loops instead of one:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
function expandAll(rows) { const contents = rows.map((row) => row.querySelector(".row-content")); // 1. read every height first — one flush, if any, covers all of them const heights = contents.map((c) => c.scrollHeight); // 2. write every height second — no reads in between to force a recalculation contents.forEach((c, i) => { c.style.height = heights[i] + "px"; }); }

Same inputs, same visual result, same number of DOM nodes touched. The only change is that every read happens before every write. The browser still has to recalculate layout once, to answer the first scrollHeight honestly — but after that, it's just queuing style changes, which it was always good at batching. One forced layout instead of N.

Runs right in your browser — poke at it and watch the concept react live.

This is the whole idea behind the old "FastDOM" pattern, and it's worth internalizing as a rule rather than a library: never interleave a layout read with a layout write inside a loop. Batch the reads, batch the writes, in that order.

It's rarely written as nakedly as the example above. It shows up as:

  • A ResizeObserver or scroll handler that reads getBoundingClientRect() on one element, then immediately updates another element's size based on it, inside a loop over a list.
  • A virtualized list or masonry layout that measures each item's height to position the next one — read, position, read, position.
  • An animation library (or a hand-rolled one) that reads the current transform of an element to compute the next frame's value, for every animated element, every frame.

The common shape is always "measure, then move, then measure the next thing" — and the fix is always the same two passes: measure everything, then move everything.

If you're not sure whether your own code does this, the profiler will tell you faster than reading the source will. Record a trace around the interaction you suspect, and search for "Forced reflow" in the summary. If it's there, it names the exact call stack that triggered it — you don't have to guess.

This isn't a feature flag or a browser quirk you need to check support tables for — every layout engine works this way, because batching writes and flushing on-demand reads is the only way to be both fast in the common case and correct when a script actually needs a real number. There's nothing to polyfill and nothing to feature-detect. The fix costs you an array and a second loop, which is a rounding error next to the forced layouts it removes.

What it does cost you is a bit of discipline: every time you write a loop that both measures and changes the DOM, ask which one happens first, and whether it happens for every item before the loop moves to the next. One extra array, two passes instead of one — that's the entire price of not blocking your own main thread on your own click handler.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Next time a click handler stutters and the network tab is empty, open the performance panel before you reach for a spinner or a setTimeout. What's the slowest "obviously fine" loop you've found hiding a forced reflow? I'd genuinely like to know — drop it in the comments.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.