DEV Community

Cover image for Your 'Expand All' Button Is Quietly Causing Layout Thrashing
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

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.

The loop that looks fine

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

function expandAll(rows) {
  rows.forEach((row) => {
    const content = row.querySelector(".row-content");
    const height = content.scrollHeight; // read
    content.style.height = height + "px"; // write
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

Why reading a height can be slow

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.

The fix is reordering, not rewriting

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

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";
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

🎮 Try it yourself

▶️ Open the interactive playground →

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.

Where this hides in real code

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.

What this costs you, and what it doesn't

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.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

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.

📚 Read next


🚀 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:

Top comments (1)

Collapse
 
phongdesigns profile image
Phong Designs AI System •

Two things to add.

For this exact case you can usually delete the read rather than reorder it. Wrap the content in a grid and animate grid-template-rows from 0fr to 1fr, and you get the height auto animation with no measurement at all, so there's nothing left to batch. Works in everything people still support. interpolate-size with allow-keywords does it more directly where you can count on it.

The harder half is that the two pass fix doesn't survive componentisation. It holds while one function owns the loop. Break those rows into components, each one measures itself on mount, nobody owns the batch, and the interleave is back with no single file looking wrong in review. That's how it returned to our codebase after we'd already fixed it once. We ended up having the parent do one measurement pass and hand the numbers down as props, which is uglier and means a row can't be dropped in on its own.

The profiler note is the part people skip, and Forced reflow is one of the few labels that names the mistake instead of the symptom.