Layout Thrashing (Forced Synchronous Reflow)
why a loop that adds no work drops you from 60fps to 8fps
Web developmentweb development / performance
Start with the model
Think of the browser's layout engine as a kitchen with a ticket board.
Every DOM write (el.style.width = "...") pins a ticket to the board. Nothing cooks yet — the browser wants to cook the whole board ONCE, right before the next paint.
Every geometry read (offsetWidth, offsetTop, scrollTop, getBoundingClientRect(), getComputedStyle()) is you walking up and shouting "is ticket #3 done?" To answer honestly, the kitchen must cook everything pending, immediately. That forced cook is a synchronous reflow.
- read, no pending writes → free (board empty)
- write, then read → 1 reflow
- write and read inside the same loop → 1 reflow per iteration
The 8fps snippet
// 200 cards
cards.forEach(card => {
const w = card.offsetWidth; // READ → forces layout now
card.style.width = (w - 20) + 'px'; // WRITE → dirties layout again
});
Each iteration dirties layout; the next iteration's read forces a full recompute. 200 layouts in one frame instead of 1. Layout on a busy page costs ~2–5ms on a mid-range phone → 400–1000ms of jank from a function that "just resizes some divs".
The fix is phase separation, not less work
const widths = cards.map(c => c.offsetWidth); // all reads → 1 layout
cards.forEach((c, i) => c.style.width = (widths[i]-20)+'px'); // all writes
Identical DOM result. 1 layout instead of 200. (This is the whole idea behind FastDOM.)
Where the analogy breaks — the intermediate part
- Not every write dirties layout. transform and opacity are compositor-only: they skip layout AND paint. Animating
leftthrashes; animatingtransform: translateX()doesn't touch the board at all. - Not every read flushes.
el.dataset.x,classList,textContentare cheap.offsetHeightandgetBoundingClientRect()are not. Learn the actual list — search "paulirish forced synchronous layouts" for the gist. - The flush is document-wide. Reading ONE card's offsetWidth recomputes layout for the whole document. Cost scales with total DOM size, not with the node you touched — which is why this bug gets worse as a page grows, with no code change.
- requestAnimationFrame does not save you. rAF means "before the next paint", not "batched". You can thrash perfectly well inside it.
How to see it, not guess it
DevTools → Performance → record an interaction. Look for a stack of purple "Layout" bars inside one long task, plus the console warning:
"Forced reflow while executing JavaScript took 143ms"
Or measure inline:
performance.mark('a');
const h = el.offsetHeight;
performance.mark('b');
performance.measure('reflow', 'a', 'b');
Your exercise
Render 500 divs. Write a function that, for each one, reads offsetTop and sets style.top = offsetTop + 1 + 'px'. Time it with performance.now().
Then split it into two loops — read all, then write all — and time it again. Send me both numbers.
Then predict: what happens if you swap style.top for style.transform = translateY(...)? Guess first, then measure and see if you were right.