Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 6.4Intermediate ~19 min · shot 4 of 7

Layout Thrashing

In one line

The browser does layout once per frame, late. If your code writes a style and then reads a layout value such as offsetHeight, the browser must do layout right now, inside your code. Repeat that in a loop and one layout becomes hundreds. The fix is to read everything first, then write everything.

0/7 · 0%

Think of it like this

A teacher marking a pile of 200 exam papers who is asked, after each one, "what is the class average so far?" She must stop and recompute the average 200 times. If she marks all 200 first and works out the average once, she is done in a fraction of the time. Layout works the same way.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

layout thrashing
Alternating reads and writes of layout-dependent values, so the browser re-runs layout over and over inside one task.
forced synchronous layout
Layout run immediately because your code asked for a size that needs it.
dirty
Marked as needing recalculation. Writes make layout dirty; the frame (or a forced read) cleans it.
batching
Grouping all the reads together and all the writes together.
coalesce
Merge many events into one piece of work per frame.

Step by step

01Press the two buttons

"Thrash or batch" has 200 rows. Press Read, write, read, write at 1×: it prints how long the loop took. Then Read all, then write all: the same job, much faster.

02Change the number of rows

Press 1 / 200 / 2000 rows and both buttons again. At 1 row the two are the same. At 2,000 the thrash takes over a second and the batch a few milliseconds.

03Read the thrash loop

It is one line, row.style.height = row.offsetHeight + 4 + "px": each write dirties layout and the next read forces it.

04Coalesce a flood of events

"A resize handler" sends ten resize events at once. A handler that works in each event runs 10 times. One that records "work is due" and works in requestAnimationFrame runs once.

Try it yourself

  1. 1

    Thrash 200 rows

    In "Thrash or batch" press Read, write, read, write a few times at 1×, then Read all, then write all. In my runs in headless Chromium: 15 to 20 ms against 0.4 to 0.5 ms.

  2. 2

    Make it hurt

    Press 1 / 200 / 2000 rows once to get 2,000 rows, then the thrash button. In my runs it took 1.4 to 1.8 seconds (the page froze), against 1 to 3 ms for the batch.

Code & diagrams

Thrash or batch

Press at 1×, not paused (a paused timeline stops `performance.now()`, so both print 0.0 ms). The time is on the page, not in the console, because it changes from run to run.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

rows: 200
A resize handler

The naive handler runs ten times, the coalesced one once, in the next frame.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

events sent: 10, naive ran: 10 coalesced ran so far: 0
after the next frame, coalesced ran: 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Put the read back inside the write loop

In the Read all, then write all handler replace the const heights line and the rows.forEach line with rows.forEach((row) => (row.style.height = row.offsetHeight + 4 + "px"));.

terminal
$ Press the batch button with 200 rows
── what you'll see ──
It takes as long as the thrash button did (15 to 21 ms in four runs, instead of 0.4 to 0.5 ms).

Myth vs fact

Myth

Reading offsetHeight is cheap because it only reads.

Fact

It is cheap when layout is clean. After a write it forces layout right now, which can cost milliseconds.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    In Chrome's Performance panel a forced layout shows as a purple Layout event nested inside your script's function in the flame chart, instead of at the end of the frame. The Long Animation Frames API reports forcedStyleAndLayoutDuration per script (Topic 6.5).

Remember this

  1. 1

    The problem this solves: a loop that looks harmless ("set each row's height from its current height") can freeze the page for a second, and nothing in the code mentions layout. The slowness hides in the order of reads and writes.

  2. 2

    Layout is lazy. When you change a style that affects size or position (height, width, top, a class), the browser does not lay out at once. It marks the layout "dirty" and recomputes it once, in the next frame, before painting. Ten writes in a row cost one layout.

  3. 3

    Forced synchronous layout. If your code then reads something that depends on layout (offsetWidth, offsetHeight, clientHeight, scrollTop, getBoundingClientRect(), or a layout value from getComputedStyle), the browser cannot answer from old numbers. It runs layout immediately, in the middle of your JavaScript, and returns the fresh value. That is a forced synchronous layout.

  4. 4

    Thrashing. Read, write, read, write in a loop forces one layout per turn, because each write dirties the layout and each read cleans it. Counted with Chrome's performance metrics, 200 rows did 200 layouts inside the read-write loop, and at most one in the read-all-then-write-all version.

  5. 5

    The numbers are worse than linear. In headless Chromium, 200 rows took 15 to 20 ms to thrash and 0.4 to 0.5 ms to batch. With 2,000 rows, thrashing took 1.4 to 1.8 seconds and batching 1 to 3 ms: ten times the rows, about a hundred times the time, because each forced layout also grows. With 1 row there is no difference.

  6. 6

    The fix. (1) Read first, write after: collect all measurements into an array, then apply all writes. (2) **Do writes in requestAnimationFrame, and coalesce many events (scroll, resize, pointermove) into one callback per frame. (3) Ask the browser for sizes without forcing layout:** ResizeObserver and IntersectionObserver deliver measurements after layout has already happened. (4) Do not measure at all when CSS can do it (aspect-ratio, grid, container queries).

Explain it without notes

01

What is a forced synchronous layout, and what causes it?

02

Why is read, write, read, write in a loop so much slower than reading everything first?

Practice

01

Fix this loop so it forces at most one layout: cards.forEach((c) => { c.style.width = c.parentElement.offsetWidth / 3 + "px"; });

02

Write a scroll handler that updates a progress bar once per frame, however many scroll events arrive.

Done when you can

  • You can say what makes layout dirty and what forces it

  • You can rewrite a read-write loop as reads first, then writes

  • You can coalesce a flood of events into one frame of work

  • You know that measuring with a paused timeline prints 0.0 ms