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:
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:
requestAnimationFrame(() => {
for (const card of cards) {
card.style.height = `${card.offsetWidth / 20}px`;
}
});
Measured in the same setup:
| Version | Time | Layouts |
|---|---|---|
| Plain loop | 122.9 ms | 1,000 |
Inside requestAnimationFrame
|
120.1 ms | 1,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. Read card.offsetWidth — The previous card's write left layout dirty, so the browser must run a full layout before it can answer
2. Write card.style.height — Marks layout dirty again, ready to hurt the next read
├─ Read and write interleaved → Next read forces a layout → One layout per card
└─ All reads, then all writes → Reads share one clean layout → 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:
| 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 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.
offsetWidthlooks 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:
// 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
offsetWidthwhile 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.
-
requestAnimationFramedoesn'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.
Originally published on Bitlyst.
Top comments (0)