HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 502 min read

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

  1. Not every write dirties layout. transform and opacity are compositor-only: they skip layout AND paint. Animating left thrashes; animating transform: translateX() doesn't touch the board at all.
  2. Not every read flushes. el.dataset.x, classList, textContent are cheap. offsetHeight and getBoundingClientRect() are not. Learn the actual list — search "paulirish forced synchronous layouts" for the gist.
  3. 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.
  4. 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.