What you'll learn: By the end of this lesson you will be able to explain why the browser delays layout, say which reads force it early, recognise layout thrashing in a loop, fix it by separating reads from writes, spot where batching cannot help, and find forced layouts with the browser's tools. You will also run a small model that counts the cost.
Picture an office manager with a seating chart. Moving a name card is quick. But if someone asks "where is Bob sitting now?" after each move, she must redraw the whole chart before answering, because Bob's seat may have shifted. Twenty questions between twenty moves mean twenty redraws. Ask all the questions first, then make the moves, and she redraws once.
The browser behaves like the manager. This lesson builds on Layout, Paint and Compositing in Depth and the short thrashing section of The Critical Rendering Path. The running example is a thumbnail gallery whose script sizes every thumbnail from its container's width.
The core idea
- Writing a style does not run layout. It marks the affected boxes as needing layout and the browser does the work later, normally once per frame.
- Reading geometry needs a current answer. If you ask for a layout-dependent value while layout is marked out of date, the browser must run style and layout first. web.dev calls this forced synchronous layout (also forced reflow).
- Layout thrashing is doing that repeatedly. web.dev: "do lots of them in quick succession".
How the browser defers layout
Changing a geometric property (width, height, left, top) invalidates layout, and web.dev notes layout "is normally scoped to the whole document". Chromium's LayoutNG article describes the older design in which an object was marked dirty "and likewise all of its parents in the tree", and the newer one in which results are cached against the constraints a parent hands down. Details are engine-specific; the shared idea is a write flags, a pass later computes.
Figure 1. The two states of layout
How to read this: the cost sits on the arrow from Dirty to Clean, which an alternating script takes once per read.
What forces layout
Paul Irish maintains a widely cited list, compiled from the Blink source, of what makes Chromium compute style and layout synchronously: box metrics (offsetWidth, offsetHeight, clientWidth, getBoundingClientRect()), scroll metrics (scrollTop, scrollHeight, scrollIntoView()), focus(), innerText, and getComputedStyle() for some properties (such as height, width and transform). The list is Blink-specific, was last updated in April 2020, and commenters report items that no longer force layout in newer Chromium. Treat it as a map to check in your own browser, not a contract.
Paint-only writes (web.dev's examples: color, background-image, box-shadow) leave layout clean, so a read after them costs nothing extra.
Illustration 1: a minimal example
// Thrashes: each iteration reads, then writes. The read after the first write forces layout.
for (const img of thumbs) {
img.style.width = `${container.offsetWidth / 4}px`;
}
With n thumbnails, reads 2..n each find layout dirty. The fix, in web.dev's words, is to "batch your style reads and do them first":
const width = container.offsetWidth / 4; // one read, layout is already clean
for (const img of thumbs) {
img.style.width = `${width}px`;
}
Figure 2. The same loop, interleaved and batched
How to read this: the expensive arrows are the reads that follow a write.
Illustration 2: a realistic example
A dashboard table sizes a bar in each row's detail panel to the row's measured width. One helper reads the width and writes the bar, and a forEach calls it per row. Nothing in the helper looks wrong, yet with 200 rows (illustrative) it forces a layout 199 times. The cure is structural: measure all rows, then write all bars.
Illustration 3: where batching cannot help
Sometimes a read depends on an earlier write: "grow this card, then ask where the next card starts". Reads-first returns different answers.
Figure 3. What to do when you find a forced layout
an earlier write?"} -->|"no"| A["Read everything first,
then write"] Q1 -->|"yes"| Q2{"Can CSS or arithmetic
give the value?"} Q2 -->|"yes"| B["Compute it in code,
or let layout do it"] Q2 -->|"no"| C["Observe sizes with
ResizeObserver, accept
one layout"]
How to read this: most thrashing loops end up in the first branch.
MDN says ResizeObserver reports changes to an element's dimensions and, following the specification, invokes its callbacks before paint, so you get sizes without calling a forcing API. Its trap: a handler that resizes what it observes can loop, and the browser then reports "ResizeObserver loop completed with undelivered notifications". It is Baseline widely available.
A small scheduler keeps the discipline in one place: it queues reads and writes separately and runs all reads before all writes (checked in Node with a fake scheduler):
export function createBatcher(schedule: (run: () => void) => void) {
const reads: Array<() => void> = [];
const writes: Array<() => void> = [];
let scheduled = false;
const flush = (): void => {
scheduled = false;
const r = reads.splice(0);
const w = writes.splice(0);
for (const read of r) read();
for (const write of w) write();
};
const request = (): void => {
if (!scheduled) {
scheduled = true;
schedule(flush);
}
};
return {
measure(fn: () => void): void { reads.push(fn); request(); },
mutate(fn: () => void): void { writes.push(fn); request(); },
};
}
In a page you would pass requestAnimationFrame as schedule. A read that depends on an earlier queued write now sees the old state: Illustration 3 again.
Trade-offs
| Approach | What it fixes | What it costs | Watch out for |
|---|---|---|---|
| Reorder: reads, then writes | Forced layouts in a loop | Restructuring | Reads that depend on writes |
| Measure/mutate queue | Interleaving across modules | A small abstraction; one frame of latency | Callbacks that read after queuing a write |
ResizeObserver |
Reading sizes after layout | Async handling | Resize loops |
| CSS-only layout | The need to measure | Design constraints | Not always possible |
Build it: count the cost of interleaving
Goal. Run a toy layout engine and see, in counters, why interleaved reads and writes multiply layout passes.
This models the idea, not a browser. There is no CSS, text or paint, boxes are stacked vertically, and the "cost" is a count of boxes recomputed, not milliseconds. Real engines differ, and nothing here was measured in a browser.
Setup. The lab is in the course's example workspace (captured on Node 26.12.1, which runs TypeScript files directly).
cd examples/frontend/labs/layout-dirty-flags
pnpm test # 23 tests
node src/demo.ts # prints the scenarios below
Steps.
- Read
src/model.ts:writesetsselfDirtyon the node andchildDirtyon every ancestor;readruns layout first if the root is dirty and counts a forced layout;endFrameruns the normal one. - In
gallery-resizethe interleaved loop does 8 passes for 8 items (7 forced, since the first read finds clean layout, plus the frame's own); the batched one does 1. - In
true-dependency, batching returns different read values (NO): Illustration 3. Inpaint-only, neither order runs layout. - In the scaling table, passes grow with
nand the reused count grows roughly with its square, because every pass walks the unchanged siblings again.
Expected output, captured from a real run (Node 26.12.1, 2026-11-08):
=== gallery-resize
lesson: Reading the container's width inside the loop that writes thumbnail widths forces a layout per item.
operations: 16 (8 reads, 8 writes)
interleaved reads= 8 writes= 8 forced= 7 frame=1 passes= 8 laidOut= 24 reused= 72
batched reads= 8 writes= 8 forced= 0 frame=1 passes= 1 laidOut= 10 reused= 2
batching returns the same read values: yes
both orders leave the same geometry: yes
=== grow-each
lesson: Read-modify-write per item thrashes; reading every height first and writing after does not.
operations: 12 (6 reads, 6 writes)
interleaved reads= 6 writes= 6 forced= 5 frame=1 passes= 6 laidOut= 18 reused= 42
batched reads= 6 writes= 6 forced= 0 frame=1 passes= 1 laidOut= 8 reused= 2
batching returns the same read values: yes
both orders leave the same geometry: yes
=== true-dependency
lesson: When a read needs an earlier write, batching changes the answers: compute in code or change the design.
operations: 10 (5 reads, 5 writes)
interleaved reads= 5 writes= 5 forced= 5 frame=0 passes= 5 laidOut= 15 reused= 30
batched reads= 5 writes= 5 forced= 0 frame=1 passes= 1 laidOut= 7 reused= 2
batching returns the same read values: NO
both orders leave the same geometry: yes
=== paint-only
lesson: A write that cannot change geometry leaves layout clean, so the read that follows is free.
operations: 16 (8 reads, 8 writes)
interleaved reads= 8 writes= 8 forced= 0 frame=0 passes= 0 laidOut= 0 reused= 0
batched reads= 8 writes= 8 forced= 0 frame=0 passes= 0 laidOut= 0 reused= 0
batching returns the same read values: yes
both orders leave the same geometry: yes
=== one-leaf-write
lesson: Changing a leaf recomputes the leaf and its ancestors; clean siblings are reused.
operations: 2 (1 reads, 1 writes)
interleaved reads= 1 writes= 1 forced= 1 frame=0 passes= 1 laidOut= 3 reused= 9
batched reads= 1 writes= 1 forced= 0 frame=1 passes= 1 laidOut= 3 reused= 9
batching returns the same read values: NO
both orders leave the same geometry: yes
=== one-container-write
lesson: Changing a container whose children are fluid recomputes every descendant.
operations: 2 (1 reads, 1 writes)
interleaved reads= 1 writes= 1 forced= 1 frame=0 passes= 1 laidOut= 10 reused= 2
batched reads= 1 writes= 1 forced= 0 frame=1 passes= 1 laidOut= 10 reused= 2
batching returns the same read values: yes
both orders leave the same geometry: yes
=== scaling: the gallery-resize loop for n thumbnails
n interleaved passes interleaved laidOut interleaved reused batched passes batched laidOut
4 4 12 20 1 6
16 16 48 272 1 18
64 64 192 4160 1 66
256 256 768 65792 1 258
Costs are counts of boxes recomputed by a toy model, not milliseconds. This is not a browser.
Break it. In write, comment out the loop that sets childDirty on the ancestors. Layout never walks down to the changed node and reads return stale geometry; 13 of the 23 tests fail. Revert it.
Stretch. Add a containment boundary that stops childDirty propagation for a fixed-size node and compare laidOut.
In practice: the dashboard that froze on "expand all"
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
An analytics dashboard has a table of 200 rows (illustrative). "Expand all" opens each row's detail panel and sizes a bar inside it to the row's width. On a mid-range laptop it freezes for a visible moment. The performance panel shows one long task with many small layout entries, and its forced-reflow insight flags the bar-sizing function.
Starting architecture
Figure 4. Reads and writes mixed in one helper
How to read this: the loop and the layout engine ping-pong inside one task, so the frame waits for all 200 rounds.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Measure all rows, then write all bars | The forced layouts | A two-phase helper | Minimal |
| Make bar width a percentage in CSS | The measuring entirely | A CSS change | Needs a design that allows it |
The decision
| Field | Decision record |
|---|---|
| Context | Per-row read-then-write inside one task forces a layout per row. |
| Decision | Use a percentage width in CSS where the design allows it, else measure first and write after. Add a review item for reads inside loops that write. |
| Consequences, good | Layout runs once for the expand; no new dependency. |
| Consequences, bad | Slightly less readable helper. |
| Revisit when | Interleaving keeps reappearing: adopt a shared queue. |
Rollout and verification
- Record "expand all" before and after in the performance panel with CPU throttling and compare forced-reflow entries.
- In the field,
forcedStyleAndLayoutDurationcan report forced layout per script; MDN marks it limited availability and experimental, so use it as a diagnostic.
The 2 a.m. failure
After release, a bar is the wrong width on narrow screens. The new two-phase helper measured rows before another code path had updated the row font size, so it read a stale width. First checks: log the order of reads and writes, and confirm the font-size update ran first. Mitigation: run that update before the measure phase. Follow-up: add a narrow-viewport test with a wrapped label.
Architect's notes
- Make "reads before writes" a review rule for loops that touch layout.
- Centralise measuring where many components touch layout.
- Measure with tools, not folklore. Use the performance panel's forced-reflow insight; lists of forcing properties are engine- and version-specific.
Common mistakes
- Reading
offsetWidthinside a loop that writes widths. - Batching reads that depend on earlier writes. The answers change.
- Resizing an element inside its own
ResizeObservercallback without a guard.
Key takeaways
- Writes mark layout dirty; reads of geometry force it to be current.
- Thrashing is alternating the two in a loop: one layout per read.
- Read everything first, then write, unless a read truly depends on a write.
- Find forced layouts with the performance panel; the model in the lab counts passes, not milliseconds.
Practice exercises
Exercise 1: Starter (30 min). Rewrite the loop from Illustration 1 so it forces no layout, then match the lab's gallery-resize numbers.
Exercise 2: Intermediate (2 hours). In a page of 200 boxes, record both versions in the performance panel and find the forced-reflow insight.
Exercise 3: Advanced (half day). Add the containment stretch to the lab.
Related reading
In this course:
- Layout, Paint and Compositing in Depth
- The Critical Rendering Path
- The Event Loop and the Main Thread
Official documentation
- web.dev: Avoid large, complex layouts and layout thrashing, forced synchronous layout and the fix.
- Paul Irish: What forces layout/reflow, the Blink-derived list of forcing APIs.
- Chrome for Developers: LayoutNG, dirty marking and constraint-based caching.
- MDN: ResizeObserver, when callbacks run and the loop error.
- MDN: PerformanceScriptTiming.forcedStyleAndLayoutDuration, measuring forced layout in the field.