DEV Community

Cover image for Layout Thrashing: Why Reading offsetWidth in a Loop Freezes the Page
Mohsen Fallahnejad
Mohsen Fallahnejad

Posted on Edited on Originally published at bitlyst.blog

Layout Thrashing: Why Reading offsetWidth in a Loop Freezes the Page

This loop updates 1,000 cards once, and it made the browser lay out the page 1,000 times:

for (const card of cards) {
  card.style.height = `${card.offsetWidth / 20}px`; // 123 ms for 1,000 cards
}
Enter fullscreen mode Exit fullscreen mode

At 60 frames per second you get about 16 ms per frame, so this freezes the page for seven frames.


The mental model: a lazy accountant

Think of the browser as a lazy accountant. When you change a style, you hand it a receipt, and it puts the receipt on a pile without adding anything up. It does the math once, right before painting the screen.

When you read a size like offsetWidth, you're asking for the total right now. The accountant has to stop and add up the whole pile before answering.

Writes go on the pile. Reads force the math.

Ask for the total after every receipt, and the accountant redoes the whole book every time. That's layout thrashing.


How it works

Setting card.style.height only marks the page as "needs layout." That's cheap, so a hundred writes in a row cost almost nothing.

Reading card.offsetWidth needs a true, current answer. If any change is still pending, the browser pauses your JavaScript and runs layout right away. This is a forced reflow.

The loop above alternates read, write, read, write, so every read finds a pending write from the card before it:

Each card in the loop, in order

1. Read card.offsetWidth    — The previous card's write left layout pending, so the browser runs a full layout before it can answer
2. Write card.style.height  — Marks layout as pending again, ready to slow down the next read
  ├─ Read and write interleaved → One layout per card
  └─ All reads, then all writes → One layout in total
Enter fullscreen mode Exit fullscreen mode

It doesn't matter that the width never actually changes. The browser can't know that without doing the math, so it does the math.

offsetWidth isn't the only trigger. offsetHeight, clientWidth, scrollTop, getBoundingClientRect() and getComputedStyle() all force the same thing. Paul Irish keeps a full list.


In practice

Read everything first, then write everything:

// before: read, write, read, write…
for (const card of cards) {
  card.style.height = `${card.offsetWidth / 20}px`;
}

// after: all reads, then all writes
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => {
  card.style.height = `${widths[i] / 20}px`;
});
Enter fullscreen mode Exit fullscreen mode
Cards Interleaved Reads, then writes
100 2.2 ms 0.5 ms
1,000 124.3 ms 4.8 ms
5,000 3,029.7 ms 21.9 ms

Headless Chrome 156, Apple M3 Pro, median of 7 runs. The interleaved version ran 1 layout per card, the batched version 1 layout in total, counted with the DevTools Protocol's Performance.getMetrics.

The cost grows much faster than the card count, because each layout has more cards to place. A list that feels fine with 20 rows in development can freeze the tab with 5,000 rows on a real account.

One common fix doesn't help: wrapping the loop in requestAnimationFrame measured 120.1 ms against 122.9 ms. It changes when the loop starts, not the read-write order inside it.

To find thrashing in your own app, record in the DevTools Performance panel and look for layout blocks marked "Forced reflow". They link to the line that caused them.


Say it in an interview

"The browser batches layout. Style changes only mark the page as needing layout, and it runs once before paint. Reading a geometry property like offsetWidth while changes are pending forces layout right away. If you alternate reads and writes in a loop, you force one layout per item. That's layout thrashing. The fix is to do all the reads first, then all the writes."


Cheat sheet

Remember Detail
Writes are cheap They only mark layout as pending
Reads can be expensive offsetWidth, getBoundingClientRect() and friends force layout if anything is pending
Thrashing Read, write, read, write gives one layout per item
Fix Batch all reads, then all writes
Not a fix requestAnimationFrame alone, because the order inside is still wrong

TL;DR

  • The browser waits and runs layout once, right before paint.
  • Reading a size after a style change forces layout to run immediately.
  • Alternating reads and writes in a loop forced 1,000 layouts for 1,000 cards here.
  • Reading everything first and writing after took 1,000 cards from 124 ms to 5 ms.

Going deeper

Advanced and optional. Everything above is enough to explain this in an interview. This part is for the curious: full numbers, edge cases and what's still unproven.

Full measurements

Cards Interleaved Layouts Reads, then writes Layouts
100 2.2 ms 100 0.5 ms 1
1,000 124.3 ms 1,000 4.8 ms 1
5,000 3,029.7 ms 5,000 21.9 ms 1

Headless Chrome 156, Apple M3 Pro, median of 7 runs (3 for the slowest row). Layout counts come from the DevTools Protocol's Performance.getMetrics. Each card is a small flex row with text in a 1,200 px wide page, and each height varies by a pixel so every write really changes something. Paint isn't included.

Why it grows faster than the card count

Fifty times the cards took about 1,400 times as long. Each height change can move every card below it, so each forced layout has more to place. More cards means more layouts, and each layout is bigger, so the two multiply.

The requestAnimationFrame result

Version Time Layouts
Plain loop 122.9 ms 1,000
Inside requestAnimationFrame 120.1 ms 1,000

requestAnimationFrame is still useful when the work is tied to a visual update. Combine it with batching: read everything, then write everything, inside the callback.

What the batched version actually does

The first read may still run one layout if the page had pending changes from earlier code. Every read after that reuses the same clean layout. The writes then pile up, and the browser handles them once in its normal rendering pass before paint.


Originally published on Bitlyst.

Top comments (0)