Rendering PipelineApr 19, 2026·8 min read·

Layout thrashing is a spec-defined hazard, not a perf tip

Every senior FE dev knows 'don't read after write'. Almost nobody can point to the WHATWG rendering algorithm that makes it a hazard or the CSSOM clauses that force layout on read. Here's the spec-level story and a live counter you can watch tick.

Two runs of the same eight operations. Hit play and count the forced layouts:

Thrashing — R/W interleaved
Each read-after-write forces a style + layout recalc mid-task.
forced layouts: 0
  1. writew1 = 100
  2. readr el.offsetWidth
  3. writew2 = 200
  4. readr el.offsetWidth
  5. writew3 = 300
  6. readr el.offsetWidth
  7. writew4 = 400
  8. readr el.offsetWidth
Batched — all writes, then all reads
The first read flushes once; subsequent reads are cached.
forced layouts: 0
  1. writew1 = 100
  2. writew2 = 200
  3. writew3 = 300
  4. writew4 = 400
  5. readr el.offsetWidth
  6. readr el.offsetWidth
  7. readr el.offsetWidth
  8. readr el.offsetWidth
Same 8 operations. Same task. Order matters.

Every perf talk has the same slide:

"Don't read layout after writing to the DOM. Read first, then write. Batch your reads."

It gets framed as a "tip" — something the browser kind of punishes you for, and the talks leave you to intuit why. The truth is less hand-wavey. Two specifications define the sequence, and violating it forces the engine to run style recalc + layout inside your task, N extra times per frame. Not "slow". Mandated by the spec.

This post pulls the two algorithms that turn "layout thrashing" into a precise term. Then it shows exactly how many recalcs you pay for a naive read/write loop versus a batched one.

tl;dr

WHATWG's event loop runs "update the rendering" once per rendering opportunity — one style + layout + paint pass per frame. The CSSOM View Module then defines a handful of synchronous properties (offsetWidth, getBoundingClientRect(), getComputedStyle(), and friends) that must return values reflecting current layout. If the DOM is dirty when you read one, the engine is forced to run style recalc and layout inline — before returning — so the number it hands back is correct. Every read-after-write is one forced mid-task layout.

The one-per-frame algorithm

WHATWG's event-loop Processing Model says the browser, on each rendering opportunity, queues a task that runs the update the rendering algorithm. That algorithm, condensed:

For each navigable that has a rendering opportunity, queue a global task on the rendering task source given navigable's active window to update the rendering:

 1. Let frameTimestamp be eventLoop's last render opportunity time.
 2. Let docs be all fully active Document objects whose event loop is eventLoop ...
 3. Filter non-renderable documents ...
 4. Unnecessary rendering ...
 7. For each doc of docs, run the resize steps for doc.
 8. For each doc of docs, run the scroll steps for doc.
 9. For each doc of docs, evaluate media queries and report changes.
10. For each doc of docs, update animations and send events.
...
13. For each doc of docs, run the animation frame callbacks.
14. For each doc of docs: Recalculate styles and update layout.
...

Step 14 is the one-per-frame budget. Everything the browser calls "rendering" — style recalc, layout, paint — is supposed to happen inside that step. Once per rendering opportunity, against the state of the DOM after animation frame callbacks have run.

Follow the spirit of the algorithm and the browser pays for one style recalc and one layout per frame. No matter how many DOM mutations you performed.

The escape hatch that ruins it

The catch: CSSOM View Module normatively defines a cluster of APIs whose return values must reflect current layout. JavaScript reads them during a task — not during a rendering opportunity — and the spec insists the returned number has to be right:

The offsetWidth attribute must return the result of running these steps:

 1. If the element does not have any associated box return zero and terminate this algorithm.
 2. Return the unscaled width of the axis-aligned bounding box of the border boxes of all fragments generated by the element's principal box, ignoring any transforms that apply to the element and its ancestors.

Note what's not there: any phrase like "based on last painted layout" or "may return a cached value". The number must describe the principal box right now. If the DOM has been mutated since the last layout, the engine cannot return a stale value — it has to run style + layout inline to compute the fresh one.

getBoundingClientRect() has the same requirement:

The getBoundingClientRect() method, when invoked on an element element, must return the result of getting the bounding box for element.

To get the bounding box for element, run the following steps:

 1. Let list be the result of invoking getClientRects() on element.
 2. If the list is empty return a DOMRect whose x, y, width and height members are zero.
 3. If all rectangles in list have zero width or height, return the first rectangle in list.
 4. Otherwise, return a DOMRect object describing the smallest rectangle that includes all of the rectangles in list of which the height or width is not zero.

The same family: offsetHeight, offsetTop, offsetLeft, clientWidth, clientHeight, scrollWidth, scrollHeight, scrollTop, getClientRects(), plus any getComputedStyle() access of a layout-affecting property (width, height, top, left, transform, etc.) and range-based rect APIs. Each one is a contract that says "you called me, I must give you the truth about layout now".

Tap through the usual suspects — which APIs force layout, which only force style recalc, and which are safe:

pick an API call — does it force layout?
el.offsetWidth
forces style + layout
Border-box width in CSS pixels.
rule · CSSOM View §7 — must return the unscaled width of the principal box. Forces layout.
Rose = full style + layout forced on a dirty document. Violet = style recalc only. Amber = depends on the property. Green = safe (no DOM access).

That's the hazard. It is not a browser quirk. It is the obligation the CSSOM View spec places on the implementation.

How engines satisfy the contract

Concretely, every engine (Blink, WebKit, Gecko) keeps a "layout dirty" bit per document. Any DOM mutation that could change geometry flips the bit on. A sync layout-reading API checks the bit; if it's dirty, the engine runs a synchronous style recalc + layout pass, flips the bit off, and then returns the value.

So the cost pattern is predictable:

  • Each write (el.style.width = ..., el.classList.add(...), el.appendChild(...), text-node change) sets dirty.
  • Each sync read on a dirty document triggers a full style + layout pass inline.
  • Each sync read on a clean document (no writes since the last layout) returns immediately from cached state.

The cost isn't the write. It isn't even the read. It's the transition from dirty to clean, and that transition happens per-read if reads and writes are interleaved.

Going back to the counter

In the visualisation at the top, the left lane accumulates four forced layouts. The right lane pays for exactly one. Same mutations, same reads, same end state; only the order changed. The engine runs the same style+layout code — the algorithm's invariants allow caching only as long as the document stays clean.

This generalises: the minimum number of forced layouts you will pay in a task is the number of read-after-write transitions in it. Inline comments — "tiny DOM tweak, will fix later" — stack the transitions up.

Why requestAnimationFrame is the cleanest schedule

Step 13 of update-the-rendering runs animation frame callbacks just before step 14 (recalc styles + update layout). That's the one place in the frame where:

  • The DOM mutations you perform will be picked up by the very next layout, with no forced recalc.
  • Layout reads immediately before step 13 will return the committed values from the previous frame (clean).
  • Nothing else in user code is fighting you for the layout dirty bit.

That's the structural reason style-mutating code belongs in requestAnimationFrame: it slots into the algorithm at exactly the point where writes are cheapest (next flush is already scheduled) and reads are cheapest (document is clean from the last frame).

ResizeObserver callbacks run inside the while-loop at step 14, after the first layout and before paint — if you mutate geometry inside them, the loop runs again until the observations stabilise. That's a spec-level guarantee that lets you batch geometry-dependent reads in one place per frame.

So-called "tips", reframed

The talks you've seen are all re-statements of the same algorithm:

  • "Batch your reads" → don't flip the layout dirty bit inside a read phase. Reads after writes force layout; reads after reads are free.
  • "Write in rAF" → slot writes into step 13 so they land in the already-scheduled step 14.
  • "Use transform instead of top/left" → transform is applied at compositing, not layout, so mutations don't flip the layout dirty bit. Watch the engine's "forced reflow" counter in devtools disappear.
  • "Avoid getBoundingClientRect in a scroll handler" → every call on a dirty document is a forced style+layout; scroll handlers fire dozens of times per frame on a high-Hz display.

None of this is wisdom you're supposed to absorb by osmosis. It's the consequence of two algorithms and one dirty bit. Read those, and every "tip" is obvious.

Primary sources