The Thrashing Golem: why your page freezes when a loop measures the DOM
One click and the page froze. Vayu, the Air guide of Domlok, shows how a loop that reads offsetWidth after every style write forces hundreds of layouts, and how reading first and writing after calms the Thrashing Golem.
Told by Vayu, with Kai asking the questions
Vayu: Every time you ask the city how tall a wall is, Kai, the builders tear it down and measure it again. You asked five hundred times in one click.
If your page freezes for a moment when you click a button that resizes or rearranges things, check for a loop that reads a size (like offsetWidth) and then writes a style, over and over. Each read after a write forces the browser to run layout again. Read everything first, then write, and the freeze goes away.
In the world of Domlok this is the work of the Thrashing Golem, a stone giant that tears itself down and rebuilds itself every time someone measures it. It never throws an error. It just eats the frame your user was waiting for.
Your guide for this episode is Vayu, the Air guide of Domlok, who keeps the realm light and fast. Loading, rendering and smooth frames are his domain, and layout thrashing is a rendering problem, so this one is his to fix. Kai, as usual, is the one who wrote the loop.
The golem wakes
Kai's gallery has a Grid view button. Each card should be three quarters as tall as it is wide, so after switching the grid he measures every card and sets its height:
const cards = document.querySelectorAll('.card');
function sizeCards() {
for (const card of cards) {
const w = card.offsetWidth; // read
card.style.height = `${w * 0.75}px`; // write
}
}
gridBtn.addEventListener('click', sizeCards);KaiMeasure the card, set the height. Two lines. What could be slow about that?
With a dozen cards you won't notice. With five hundred, the click locks up the page. Nothing in the loop is expensive on its own. The order is the problem.
How the browser builds a frame
For each frame, the browser normally works in this order:
- JavaScript: your code runs and changes the DOM or styles.
- Style: the browser works out which CSS rules apply to which elements.
- Layout: it calculates the size and position of every box.
- Paint and composite: it draws the pixels.
When you change a style, the browser doesn't lay out the page straight away. It marks layout as out of date and does the work once, after your script finishes. That is cheap, however many styles you change.
VayuBut ask for a size while layout is out of date, and the browser can't wait. It has to run layout right now, in the middle of your script, so it can give you a correct number. That is a forced reflow, also called a forced synchronous layout.
Kai's loop does exactly that on every card: the write makes layout dirty, and the read on the next card forces it to run. Five hundred cards, about five hundred layouts in one click. Doing this in a loop has a name: layout thrashing.
Why it hurts
- It all happens in one task. At 60 frames a second, a frame has about 16 milliseconds. A loop that forces hundreds of layouts can hold the main thread far longer, so the page can't respond to taps, typing or scrolling until it finishes.
- Each layout can be big. The browser may have to re-lay out much more than the one card you changed, often the whole document, because it can't always tell what your change affects.
- It hurts responsiveness. A click that kicks off a long task like this delays the next paint, which is exactly what Interaction to Next Paint (INP) measures.
KaiSo the browser isn't slow. I made it rebuild the page five hundred times.
The fix: read first, write after
Keep the same work and change the order. Do all the reads in one pass, then all the writes in a second pass.
function sizeCards() { // 1. read everything
const widths = [...cards].map(c => c.offsetWidth); // 2. then write everything
cards.forEach((c, i) => {
c.style.height = `${widths[i] * 0.75}px`;
});
}Now layout runs at most once, at the first read (and not at all if nothing changed since the last frame). The remaining reads use that fresh layout for free. The writes then mark layout dirty once, and the browser lays out the new heights together before it paints.
In event handlers: read now, write in requestAnimationFrame
When reads and writes live in different parts of your code (a resize handler here, a component there), it is hard to keep them in order by hand. A common pattern is to read straight away and defer the writes to requestAnimationFrame, which runs just before the next repaint:
window.addEventListener('resize', () => {
const w = grid.offsetWidth; // read now
requestAnimationFrame(() => { // write later
grid.style.setProperty('--cols', Math.floor(w / 240));
});
});Libraries such as FastDOM do the same thing for you: they queue reads and writes and run all the reads, then all the writes, once per frame.
Best of all: the read you never make
Kai's cards don't need JavaScript at all. CSS can keep a box at a fixed ratio, and the browser works it out during its own layout:
.card { aspect-ratio: 4 / 3; }The card gets its width from the grid and its height from the ratio. No reads, no writes, no golem. (The ratio is used when one of the two sizes is automatic, so don't also set a fixed height.)
VayuThe fastest measurement is the one you never take. Reach for CSS first, and batch your reads when you really need them.
Reads that wake the golem
Any property or method that needs up-to-date geometry can force layout if it follows a write. These are the ones you will meet most often:
| Kind of read | Examples |
|---|---|
| Element box size and position | offsetWidth, offsetHeight, offsetTop, clientWidth, clientHeight |
| Bounding boxes | getBoundingClientRect(), getClientRects() |
| Scrolling | scrollTop, scrollHeight, scrollWidth, scrollIntoView() |
| Computed styles | getComputedStyle(el).width and other layout-dependent values |
| Window size and scroll | innerWidth, innerHeight, scrollX, scrollY |
| Others | el.focus(), innerText |
Reading them is only expensive when styles or the DOM changed since the last layout. Read them first and they cost almost nothing.
Catch the golem in DevTools
- Open Chrome DevTools and go to the Performance panel.
- Click Record, click the button that freezes, then stop recording.
- On the Main track you'll see one long task. Under your function, look for a dense row of purple Layout blocks.
- Open the Insights sidebar. The Forced reflow insight flags interleaved reads and writes and points to the function that caused them.
- Fix the order and record again. The wall of purple should shrink to a single Layout.
KaiOne layout instead of five hundred. The click is instant and the golem is gravel!
Before you merge a loop that touches the DOM
- Could CSS do this instead (
aspect-ratio, grid, flexbox, container queries)? - Does any read (
offsetWidth,getBoundingClientRect(),scrollTop…) come after a write in the same loop? - Are all reads collected first, and all writes done after?
- Does a Performance recording of the interaction show one Layout, not a wall of them?
Acharya's Rule #10Read everything first, then write. A read after a write forces a new layout.
Next episode: The Key Thief. Why your React list mixes up whose input is whose when you use the index as a key, with Chitragupta, keeper of records.
Think you can calm the golem yourself? Play Calm the Thrashing Golem on domlok.in and earn seal 10.
Watch the episode on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.