Reading and writing cost differently
Writing to the DOM is queued and cheap. Reading certain properties forces the browser to do all the queued work immediately, and doing that in a loop is a well-known way to make a fast page slow.
The browser is lazy on purpose. When you change a style, it does not immediately recompute the layout, it marks the page dirty and batches the work until it needs the result, usually just before the next frame. That laziness is most of why DOM manipulation is fast enough to be usable at all.
But some things you can read are only knowable after layout has run. offsetTop, offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle(), focus(), ask for any of these and the browser cannot answer from stale data. It must flush everything pending and lay out immediately. That is a forced synchronous layout, and one of them is fine.
The problem is the loop. Write a style, read a geometry, write, read, fifty times. Every read forces a layout that the write just invalidated, so you have caused fifty layouts where one would have done. This is layout thrashing, and it is the most common performance bug in hand-written DOM code, common enough that it has a name and that Chrome DevTools flags it specifically.
The fix is structural and simple: read everything first, then write everything. The reads all come from one layout because nothing invalidated it in between; the writes all batch because nothing read in between. Fifty layouts become one, and the code is usually clearer as well.
The instrument below runs both versions side by side with a forced-layout counter. Watching it climb to fifty in one column and stay at one in the other is more persuasive than the paragraph you just read. Then do the reorder exercise at the bottom, three statements, and getting them in the right order is the entire skill.
Fifty forced layouts, or one
for (const box of boxes) { // 50 of them
box.style.width = grow(box) + 'px'; // write: marks layout dirty
total += box.offsetHeight; // read: forces layout NOW
}forced layouts
0
iterations
0
frame
0.0ms
One frame: 16.7ms to spend
Not run yet.
const heights = boxes.map((b) => b.offsetHeight); // read all
const next = heights.map(grow); // no DOM
boxes.forEach((b, i) => { // write all
b.style.width = next[i] + 'px';
});forced layouts
0
iterations
0
frame
0.0ms
One frame: 16.7ms to spend
Not run yet.
All of these are questions the browser cannot answer from a dirty layout. None of them is slow on its own: each one is slow when a write happened on the line above.
// what it is now: 50 forced layouts
for (const b of boxes) {
b.style.width = b.offsetWidth * 1.1 + 'px';
}- 1
boxes.forEach((b, i) => { b.style.width = `${next[i]}px`; });write pass - 2
const widths = boxes.map((b) => b.offsetWidth);read pass - 3
const next = widths.map((w) => w * 1.1);arithmetic, touches no DOM
Every DOM read first, then the arithmetic, then every DOM write. Reorder and check.
Run each version. Watch the forced-layout counter rather than the milliseconds: the counter is the part that does not depend on your hardware.
To find it in real code: open the Performance panel, record an interaction, and look for the purple layout blocks. A long row of them is thrashing, and Chrome will often label the offending read for you. Then look at where it is: in your own code, or in a library, or, increasingly often, in something a model generated, because the interleaved version is the one that reads more naturally and therefore appears more in training data.
Worth reading
- Avoid large, complex layouts and layout thrashing ↗, The canonical write-up, with the full list of properties that force layout.
- Chrome DevTools: forced reflow insight ↗, How to find it in a recording, which is the part you will actually use.
You should now be able to
- Explain forced synchronous layout
- Identify the reads that trigger it
- Restructure a loop to batch reads and writes
- Find the pattern in the Performance panel
Loading…