Under the Hood
Frontend

Layout thrashing: the forced synchronous layout trap

The browser is lazy about layout on purpose, batching every style and DOM change in a tick into one pass right before paint. Reading certain geometry properties breaks that laziness by demanding an up-to-date answer immediately, and a loop that alternates reads and writes turns one cheap layout into dozens of expensive ones. This lesson walks through why that happens, fixes it with a read-then-write pass, and lets you measure the cost live in your own browser.

Layout thrashing: the forced synchronous layout trap

Here's a loop that looks completely reasonable: for each of forty elements on the page, read its current width, then write a new width back based on what you read. Nothing about that sentence sounds expensive. And yet this exact pattern, run on a real page, can take an order of magnitude longer than the same work done in a different order — same forty reads, same forty writes, same final result on screen, just organized differently. The only thing that changed is whether a read ever sits immediately after a write.

The layout, paint, composite lesson described layout as something that happens once per frame, right before the browser draws — a single pass that gathers up everything that changed since the last paint. That "once per frame" claim has a hidden assumption baked into it: it's true only as long as nothing forces the browser to compute layout early. This lesson is about the one everyday coding pattern that breaks that assumption, why it breaks it, and the two-line reordering that fixes it for good.

The browser's normal laziness

When you change a style, add a class, or edit an element's text, the browser does not go recompute layout right then. It marks the affected part of the render tree "dirty" — a note to itself that says, roughly, "this subtree's geometry is no longer trustworthy" — and moves on to the next line of your code. Layout itself is deferred until the browser genuinely needs it, which in the normal case is right before the next paint.

That deferral is not laziness in the pejorative sense; it's a deliberate optimization, and a good one. If your code makes ten style changes in the same tick, the browser doesn't want to recompute geometry ten times — it wants to notice that ten things got dirtied, wait until you're done making changes, and then run layout exactly once, accounting for all ten changes together. One layout pass instead of ten is strictly better, and it costs you nothing extra as the person writing the code, because you never asked to see an intermediate geometry value. The browser is free to batch because nothing in a normal write-only sequence ever demands an answer mid-stream.

Why some reads can't wait

That last sentence is the crack this lesson lives in: "nothing ever demands an answer mid-stream" stops being true the moment your code reads certain properties. element.offsetWidth, offsetHeight, getBoundingClientRect(), scrollTop, clientWidth, and any computed style that depends on an element's box (like getComputedStyle(el).height) are all geometry values — numbers that only mean something if layout is currently correct.

The browser cannot hand you a lazy or stale answer to "what is this element's width right now" if there's dirty, unprocessed layout state sitting between the last real answer and this instant. Doing so would just be wrong — you'd get a number that doesn't reflect the change you made three lines ago. So instead, the moment you read one of these properties while anything is dirty, the browser drops what it was deferring and computes layout immediately, synchronously, on your call stack, purely to have a correct number to hand back to you. That's a forced synchronous layout, also called a forced reflow: layout running now, out of its normal once-per-frame schedule, because a read demanded it.

A single forced layout isn't necessarily a disaster. The disaster is what happens when a forced layout is followed by another write, which is followed by another forced-triggering read, over and over.

How a loop turns this into a disaster

Picture a loop over forty elements, and for each one you read offsetWidth and immediately write a new width derived from it:

// Interleaved: read, write, read, write... 40 times
const els = document.querySelectorAll(".card");
els.forEach((el) => {
  const width = el.offsetWidth; // read
  el.style.width = width * 1.1 + "px"; // write, immediately after a read
});

Trace what actually happens, iteration by iteration. Iteration 1 reads offsetWidth. At that point layout is presumably clean (nothing's been touched yet), so the browser can answer instantly, no forced layout needed — this first read is free. Then iteration 1 writes a new width, which dirties that element's layout.

Iteration 2 reads offsetWidth on the next element. It doesn't matter that this is a different element than the one just written to — the browser doesn't track dirtiness with per-element precision fine enough to say "only element 1 is stale, element 2 is definitely still fine." Layout is dirty, full stop, and any geometry read while anything is dirty forces a full recomputation to guarantee correctness. So iteration 2's read cannot simply reuse iteration 1's layout pass — the write in between invalidated it, and the browser has no way to hand back an answer without running layout again, right now. Iteration 2 forces a synchronous layout, reads the (correct) value, then writes again, dirtying things afresh for iteration 3.

Every iteration after the first repeats that same cycle: write dirties, read forces, write dirties, read forces. Forty elements handled this way cost roughly forty forced synchronous layout computations — not the one layout pass the browser would have happily batched for you if you'd just left it alone. Each of those forty layouts might only need to re-measure a small part of the page, but "small" here is deceptive: layout, as the earlier lesson explained, is a whole-subtree computation, so each forced pass can end up touching far more of the document than the one card you're currently looking at. Multiply a non-trivial cost by forty instead of by one, and on a busy page this is exactly the kind of work that blows through your frame budget and shows up as visible jank — the same frame budget the rAF batching lesson is protecting from a different angle. That lesson batches along the time axis — collapsing many events into one write per frame. This lesson batches along the read/write order axis — collapsing many layout passes into one by never interleaving them. Different axis, same governing idea: don't make the browser redo work it would gladly have done once.

The fix: separate every read from every write

The principle is simple to state and doesn't depend on any particular library: read everything you need first, across every element, before you write anything. Because no write happens between any two reads, nothing gets dirtied in the middle of your reading pass — every read is answered from the same, single, already-correct layout. Then perform all the writes, as a second pass. Those writes dirty layout, sure, but nothing after them reads geometry, so there's no reason for the browser to compute anything synchronously — it just queues the dirty state and handles it in one batched pass at the normal time, right before the next paint.

Here's the same forty-element example, corrected:

// Batched: read all 40 first, then write all 40
const els = document.querySelectorAll(".card");

// Pass 1: reads only. Layout is never dirtied between any two of these,
// so all 40 reads are answered by a single up-to-date layout.
const widths = Array.from(els).map((el) => el.offsetWidth);

// Pass 2: writes only. Nothing reads geometry after this point in the
// loop, so these 40 writes just queue up for one lazy batched layout.
els.forEach((el, i) => {
  el.style.width = widths[i] * 1.1 + "px";
});

Same forty reads, same forty writes, same final DOM state — the only change is the order. One forced-or-natural layout instead of forty. This is the whole trick: it's never about doing less work, it's about not asking the browser questions it has to answer synchronously in between the writes that keep invalidating the answer.

Some codebases formalize this discipline with a small scheduler — libraries in the "FastDOM" family collect every read callback and every write callback separately and flush all reads before any write, every frame — and several UI frameworks batch their own internal DOM writes for exactly this reason. You don't need a library to get the benefit; the two-pass structure above is the entire idea, and you can apply it by hand anywhere you find yourself reading geometry inside a loop that also writes it.

See it, don't just take it on faith

Below is a live benchmark, not a canned number. It runs the identical forty-element read-plus-write workload two ways in your actual browser — once interleaved, exactly like the first code block above, and once batched, exactly like the second — and times each with performance.now(). Click the button, then compare the two millisecond numbers it reports. Before you click, you should already be able to predict the shape of the result from the walkthrough above: the interleaved run should consistently come in slower than the batched run, and the gap between them is the real, measured cost of the extra forced synchronous layouts — not an estimate, a number your own machine just produced.

Interleaved (read, write, read, write…):

Batched (all reads, then all writes):

Exact numbers depend on your device and how many boxes are on screen, but the interleaved run should consistently be slower — every offsetWidth read flushes the layout the previous write just invalidated, so N boxes cost N forced layouts instead of one.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why does the browser defer layout until right before paint instead of recomputing it the instant a style changes?
  2. You read offsetWidth on element B right after writing a style to element A. Why does that read force a synchronous layout even though B itself was never written to?
  3. Walk through iterations 1 and 2 of an interleaved read-write loop and explain precisely why iteration 2's read cannot reuse anything computed for iteration 1.
  4. Why does reordering a loop into 'all reads, then all writes' reduce forty forced layouts down to roughly one, when the total number of reads and writes hasn't changed?
  5. Name three DOM properties or methods whose reads can force a synchronous layout, and explain what they have in common that a property like style.color reading doesn't share.
  6. A teammate wraps a layout-thrashing loop in requestAnimationFrame, hoping that fixes it, but the loop still interleaves reads and writes inside that single callback. Does the rAF wrapper help? Why or why not?
  7. In the live demo, both runs produce the identical final DOM state. What exactly accounts for the timing difference between them, if not the amount of work done?