BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/javascript
Advanced5 min read

Part 1 of Under the Benchmark

1,000 Cards, 1,000 Layouts, and requestAnimationFrame Didn't Help

What layout thrashing and forced reflow are, why reading offsetWidth after a style change is so slow, and how batching reads and writes fixes it.

Mohsen Fallahnejad

Mohsen Fallahnejad

Nov 6, 2026Discuss
code
LayoutCount: 1000

That number came from a loop that updates 1,000 cards, one time, inside one JavaScript task. A browser that's rendering a single frame should need to lay out the page once. This loop made it do so a thousand times, and it took 123 ms to get there.

Here's the loop. It sets each card's height based on its width:

js
for (const card of cards) {
  card.style.height = `${card.offsetWidth / 20}px`;
}

It's ordinary DOM code, and at 60 frames per second, you only get about 16 ms per frame. Picture this running when a dashboard opens. The page freezes for seven frames, and the code looks like it has nothing to apologize for.


The wrong turn: requestAnimationFrame#

The standard advice for janky DOM work is "put it in requestAnimationFrame." It sounds right. The browser wants visual changes done at the start of a frame, so give them to it there:

js
requestAnimationFrame(() => {
  for (const card of cards) {
    card.style.height = `${card.offsetWidth / 20}px`;
  }
});

Measured in the same setup:

VersionTimeLayouts
Plain loop122.9 ms1,000
Inside requestAnimationFrame120.1 ms1,000

No change, because timing was never the problem. requestAnimationFrame decides when the loop starts. It can't change what happens between the lines inside it.


The reveal: the browser is lazy, and your read cancels it#

The browser doesn't do layout when you change a style. Setting height only marks the page as needing layout, and the work waits until it's really needed, normally once, right before painting. That's why a hundred style writes in a row cost almost nothing.

But offsetWidth is a question, and it needs a true answer right now. If the page has pending changes, the browser can't answer from stale numbers, so it stops your JavaScript and runs layout immediately. This is a forced reflow.

A loop that alternates writes and reads ends up forcing one every time around:

Each card in the loop, in order

  1. 1

    Read card.offsetWidth

    The previous card's write left layout dirty, so the browser must run a full layout before it can answer

  2. 2

    Write card.style.height

    Marks layout dirty again, ready to hurt the next read

then it depends
Read and write interleaved
  1. ↳Next read forces a layout
  2. →One layout per card
All reads, then all writes
  1. ↳Reads share one clean layout
  2. →One layout at the end
Nothing in the code says 'run layout'. It's a side effect of a read that looks like a plain property.

The painful detail is that, in this test, the width doesn't even depend on the height. Every card is exactly as wide before and after. But the browser can't know that without doing the work, so it does the work.

The other nasty part is how this scales. Each height change can move every card below it, so each forced layout has a lot to look at. Here's how the time grew with card count:

CardsInterleavedLayoutsReads, then writesLayouts
1002.2 ms1000.5 ms1
1,000124.3 ms1,0004.8 ms1
5,0003,029.7 ms5,00021.9 ms1

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 the real test varies each height by a pixel so every write changes something. Paint isn't included.

Fifty times the cards took about 1,400 times as long. That's the shape of a problem that works fine in development with 20 rows, then locks up the tab on a real account. At 5,000 cards, the interleaved version takes three full seconds and batching takes 22 ms, which is roughly 140 times faster.

offsetWidth looks like reading a variable. After a style change, it's a function call that lays out your page.

offsetWidth isn't special. offsetHeight, clientWidth, scrollTop, scrollHeight, getBoundingClientRect(), getComputedStyle() and many others can all force the same thing. Paul Irish keeps a list of what forces layout.


The fix#

Read everything first, then write everything. Same data, two loops:

js
// 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`;
});

The first read might run one layout if the page was dirty, and the rest reuse it. The writes then pile up and get handled once by the browser's normal rendering pass. The cost of 1,000 cards fell from 124.3 ms to 4.8 ms.

This doesn't need to be inside requestAnimationFrame to work, although combining them is good when the work is tied to a visual update. What counts is the order inside.

To find these in your own app, record in the DevTools Performance panel and look for layout blocks with the "Forced reflow" warning. It links back to the line in your code that triggered it.


The rule#

Never read layout after writing styles in a loop. Do all your reads first, all your writes after, and let the browser lay out once.


TL;DR#

  • Style writes only mark the page as needing layout, and the browser normally runs that layout once, right before paint.
  • Reading a geometry property like offsetWidth while layout is pending forces the browser to run layout immediately, even if the value you read isn't affected.
  • Alternating reads and writes in a loop caused one full layout per card here, and 5,000 cards took 3,029.7 ms instead of 21.9 ms.
  • requestAnimationFrame doesn't fix this, because it changes when the loop starts and not the order of operations inside it.
  • Batch all reads before all writes, and use the Performance panel's forced reflow warnings to find the offending line.
Filed under#javascript#browser#performance#layout#dom#rendering

Series

Under the Benchmark

Same code, wildly different speed. Measured explanations of what the engine and hardware are really doing.

  1. 11,000 Cards, 1,000 Layouts, and requestAnimationFrame Didn't Help · you are here
  2. 2A Fifth Object Shape Made the Same Loop Twice as Slow
  3. 34 KB of Text Was Holding 200 MB of Memory
  4. 4Shuffling an Array Made the Same Loop Seven Times Slower
  5. 5Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper
Next part: A Fifth Object Shape Made the Same Loop Twice as Slow →

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderKeeping Ten Times More Objects Made Garbage Collection Four Times CheaperNewer →Shuffling an Array Made the Same Loop Seven Times Slower

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

Web Workers Explained for Frontend Developers

How Web Workers work, why they matter, and when to use them to achieve real parallelism in frontend apps.

Dec 30, 20253 min read2

Shuffling an Array Made the Same Loop Seven Times Slower

How memory locality and CPU cache misses affect JavaScript: why array order changes loop speed for large arrays of objects, and when typed arrays help.

Nov 6, 20266 min read

Keeping Ten Times More Objects Made Garbage Collection Four Times Cheaper

How V8's generational garbage collector really charges you: scavenges cost per survivor, and allocation site pretenuring can make long-lived objects cheaper.

Nov 6, 20267 min read

Get new posts in your inbox

No spam. Unsubscribe any time.

5 min left
Discuss👍 React
👍