LayoutGENERALBROWSER-SPECIFICSIMULATED

Layout Thrashing

Read a layout property, write a style, read again: each read forces the browser to recompute the geometry you just invalidated, synchronously, inside your loop.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Why does a loop that only reads two properties per element cost more than the render it triggers?

The user intent

Someone wants a row of cards to end up the same height as the tallest one. Measure each card, find the maximum, set them all. It is three lines and it is obviously correct.

The obvious build

For each card: read its height, compare to the running maximum, then set the height. One pass, no framework, no library.

Why it breaks

The loop is O(n) in reads and O(n) in writes, but the browser runs a full layout between each pair, so the real cost is n layouts of the whole document — with a hundred cards that is a hundred layouts, not one.

How it breaks in a real browser
  • The loop is O(n) in reads and O(n) in writes, but the browser runs a full layout between each pair, so the real cost is n layouts of the whole document — with a hundred cards that is a hundred layouts, not one.
  • Nothing in the code looks expensive. There is no allocation, no network, no recursion. The cost lives entirely between the lines, in work the browser does that no profiler line-attributes to your source (Measure Before Optimising).
  • The symptom is not "the function is slow" but "the page froze on hover". A forced layout inside an input handler blocks the frame the user was waiting for, so it presents as unresponsiveness rather than as a slow function (Interaction Responsiveness).
  • It gets dramatically worse with page size, because each forced layout is a layout of everything dirty — the cost is a property of the document, not of the elements in your loop.
  • The same pattern hides inside libraries. A tooltip positioner, an autosizing textarea, a masonry layout or a scroll-spy that measures in a loop produces identical stalls from code you did not write.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Layout is lazy and batched. A style write does not lay anything out; it marks boxes dirty and returns. The browser intends to compute layout once, later, at the rendering opportunity before the next frame (The Rendering Opportunity).
  • A layout-dependent read cannot be answered from a dirty tree. offsetTop, getBoundingClientRect(), scrollHeight, getComputedStyle() and their relatives must return correct values, so the browser flushes: it runs style recalculation and layout synchronously, right there, before returning from your property access.
  • That is a forced synchronous layout — often called reflow. One of them, once, is fine and sometimes necessary. The problem is the loop: write, read, write, read. Every write re-dirties what the previous read just cleaned, so every read pays for a full layout again.
  • The fix is not to read less but to separate the phases: do every read first, while the tree is clean, then do every write. The tree is invalidated once and laid out once, at the browser's own schedule.
  • getComputedStyle() forces style recalculation, and forces layout too whenever the property asked for is layout-dependent — which includes width, height and anything resolved against a used value. It is not a cheap read.
  • Some reads are cheap because they do not depend on layout: element.className, a dataset value, an attribute, a cached number in your own state. Anything that answers "where is this on screen" is not one of them.
  • ResizeObserver and IntersectionObserver answer the same questions asynchronously, from the browser's own layout pass, with no forced flush at all — which is why they exist.

What this makes the browser do

And which of it is avoidable.

  • A forced layout runs style recalculation and layout for everything currently dirty, not only for the element you touched. Scope is document-wide unless containment bounds it (CSS Containment).
  • The work is duplicated rather than merely early: the same layout will be computed again before the frame is drawn if anything is dirtied afterwards. A thrashing loop of n iterations does the same total layout n times.
  • contain: layout and content-visibility bound the subtree a forced layout must traverse, which turns a document-sized cost into a component-sized one. It reduces the cost; it does not remove the flush.
  • The compositor can keep scrolling and running composited animations while the main thread is stalled, so a thrashing loop often produces a page that scrolls smoothly and responds to nothing (Compositing Layers).

The loop everybody writes

Equal-height cards, measured and set. It is three lines, it has no obvious cost, and it is the canonical example because the mistake is invisible: the interleaving is between the statements rather than in any one of them.

Read the loop as the browser does. card.offsetHeight is a question about geometry. The previous iteration wrote style.height, so the tree is dirty and the question cannot be answered from cached values. The browser recalculates style and runs layout — for the whole dirty tree — and only then returns a number. Then the next write dirties it again.

Equal-height cards
Read, write, read, write — n layouts
const cards = document.querySelectorAll('.card')
let tallest = 0

for (const card of cards) {
  //  READ: the tree is dirty from the last write.
  //  the browser must run style + layout right here, synchronously.
  tallest = Math.max(tallest, card.offsetHeight)

  //  WRITE: dirties the tree again for the next iteration.
  card.style.height = tallest + 'px'
}
Read all, then write all — one layout
const cards = [...document.querySelectorAll('.card')]

// phase 1 — READ. the tree is clean; every read is answered from cache.
const heights = cards.map((card) => card.offsetHeight)
const tallest = Math.max(...heights)

// phase 2 — WRITE. the tree is dirtied once and laid out once,
// at the browser's next rendering opportunity.
for (const card of cards) {
  card.style.height = tallest + 'px'
}

// phase 0 — better still: delete both phases.
// .cards { display: grid; grid-auto-rows: 1fr; }

Layout is lazy: a write only marks boxes dirty, and the browser intends to lay out once before the next frame. A layout-dependent read cannot be answered from a dirty tree, so it forces a full synchronous layout. Interleaving turns one deferred layout into n forced ones — and the CSS version turns it into zero, because the browser was always going to compute those heights anyway.

What the browser is doing between your lines

SIMULATEDRelative units from an Engineer Atlas model, showing interleaving shape only. Real ratios depend on document size, device class and how much of the tree each layout must traverse; measure your own in the Performance panel rather than transferring these proportions.

The timeline below is the same work drawn twice: interleaved, then batched. The units are relative and the ratio between script and layout varies wildly with document size — the shape is what transfers, not the numbers.

Notice where the frame lands. In the interleaved version the task is still running when the browser wanted to produce a frame, so input queued during it waits and the frame is late. In the batched version the same total work fits, because the layout happens once at the point the browser had planned for it all along.

Four cards, interleaved versus batchedrelative units — a shape from an Engineer Atlas model, not a measurement
Task starts (click handler)
read #1 → FORCED LAYOUT
write #1
read #2 → FORCED LAYOUT
write #2
read #3 → FORCED LAYOUT
write #3
read #4 → FORCED LAYOUT
write #4
Input waiting on the main thread
Frame the browser wanted here
Final layout + paint (late)
— batched version below —
Task starts
reads #1–#4 (tree is clean)
writes #1–#4
Rendering opportunity: layout once
Paint + composite, on time
  • read #1 → FORCED LAYOUTThe first read is legitimate: something earlier dirtied the tree.
  • write #1Re-dirties everything the layout just cleaned.
  • read #2 → FORCED LAYOUTFull layout again. Nothing has been reused.
  • Input waiting on the main threadThe click that arrived mid-loop cannot be dispatched until the task ends.
  • Frame the browser wanted hereMissed — the task is still running.
  • reads #1–#4 (tree is clean)All four answered from cached geometry. No flush.
  • writes #1–#4Dirties the tree once.

The total layout work in the batched version is roughly one of the four stalls above it. Nothing was optimised — the same boxes were laid out with the same algorithm. The reads simply stopped asking for answers the browser had not computed yet.

Which reads force layout — and what to use instead

There is no way to memorise the exact list, and there is a reliable heuristic: if the answer describes where something is or how big it is on screen, it cannot come from a dirty tree. If it describes what you told the DOM, it can.

The right-hand column matters more than the left. Most forced layouts are answering a question that has a non-forcing answer — an observer, a CSS feature, or a value you already had in your own state.

Thrashing in code you did not write
TriggerSymptomCauseResponse
An autosizing textarea libraryTyping lags by a word on a mid-range phoneIt sets height: auto, reads scrollHeight, then writes the height — on every input eventDebounce to a requestAnimationFrame, or use field-sizing: content / a CSS grid autosize trick with no measurement at all.
A scroll-spy or parallax handlerScrolling is smooth but nothing on the page respondsEvery scroll event measures every section with getBoundingClientRect()IntersectionObserver with thresholds; the compositor keeps scrolling either way, which is why it looks fine (Passive Listeners).
A ResizeObserver callback that sets a sizeA console warning about an undelivered notification loopThe write changes the observed box, which schedules another observationWrite to a different element, or bail out when the value has not changed. The browser defers rather than loops, so it is a staleness bug, not a hang.
A framework effect that measures on mountA long task on every route change, growing with list lengthOne forced layout per mounted child, interleaved with each child's own DOM writesMeasure once for the whole list after the batch, or move the requirement into CSS (What a Component Costs to Render).
A third-party widget on the pageForced reflows in the Performance panel with a stack you do not recogniseSomebody else's measuring loop, on your main threadThe stack names it. Contain it with contain: layout, load it lazily, or replace it (Third-Party Scripts and the Supply Chain).
You want to knowThe forcing readWhat it flushesNon-forcing alternative
How big is this elementoffsetWidth / offsetHeight / getBoundingClientRect()Style + layoutResizeObserver — delivered from the browser's own layout pass
Is this element visiblegetBoundingClientRect() compared to the viewportStyle + layoutIntersectionObserver — same answer, asynchronous, no flush
Where is this scrolled toscrollTop / scrollLeft / scrollHeightStyle + layoutA scroll listener's cached value, or IntersectionObserver for thresholds
What is this element's computed stylegetComputedStyle(el) then a layout-dependent propertyStyle, and layout if the property needs itA custom property you set, or your own application state
Does the text overflowscrollWidth > clientWidthStyle + layoutResizeObserver, or let CSS handle it with text-overflow and no measurement
How tall is the tallest siblingA measure-and-set loopStyle + layout, once per iterationdisplay: grid with grid-auto-rows: 1fr — the browser does it in its own pass (Grid: Two Dimensions at Once)
Where should this tooltip gogetBoundingClientRect() on the anchorStyle + layoutCSS anchor positioning, or measure once per open rather than per frame

How to build it

Most important first.

  • Batch: read everything, then write everything. This single restructuring is the whole lesson, and it usually costs one array.
  • Split across the frame when the batches are large. Read in one requestAnimationFrame callback and write in the next, or read during the callback and write at the end of it — never interleave (Yielding and Scheduling).
  • Prefer an observer to a measurement. ResizeObserver for "how big is this now", IntersectionObserver for "is this visible" — both are delivered from the browser's own layout pass and force nothing.
  • Prefer CSS to measurement altogether. Equal-height cards are a grid row; a sticky header is position: sticky; an aspect-locked box is aspect-ratio. Every one of those is a measurement you no longer perform (Grid: Two Dimensions at Once).
  • Cache what you already know. If you set the width three lines ago, you know the width; reading it back asks the browser to prove it.
  • Bound the blast radius with contain: layout on components that are laid out independently, so an unavoidable forced layout stays local (CSS Containment).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A stalled main thread stalls assistive technology with it: focus moves late, live-region announcements queue, and a screen-reader user experiences the freeze with no visual cue that the page is busy (What the Main Thread Owns).
  • Focus itself can force layout — the browser must scroll the focused element into view, which requires geometry. A tab press during a thrashing loop is therefore the slowest possible moment to press Tab.
  • Measurement-driven layout tends to be measurement of *one* state. A layout computed once and cached does not respond to a user increasing text size or zooming, so the manual layout silently stops adapting where a CSS layout would have (Intrinsic Sizing and the Automatic Minimum).
  • Any "measure then position" tooltip or dropdown must re-measure on zoom, on font load and on text-spacing changes, or it will be positioned against geometry that no longer exists for that user (Positioning and Stacking Contexts).

What can go wrong

Failure modes
  • The hidden read: a library, a polyfill or a framework ref callback measures inside your loop, so the interleaving is invisible in your own source.
  • A ResizeObserver callback that writes a style which changes the observed element's size, producing an observation loop. The browser detects it, warns, and delivers the remaining notifications on the next frame — so the layout is one frame stale rather than wrong.
  • Batching implemented as "read all, write all" per element rather than across all elements: still one layout per element, just tidier to read.
  • requestAnimationFrame used to defer work that then reads layout at the start of the callback — after other code has already written. The callback ordering matters as much as the batching.
  • Fixing thrashing by caching values that then go stale, so the layout is now fast and wrong. Cached geometry needs an invalidation story, which is the cost of the fix.
  • Optimising the loop away and leaving one forced layout inside an input handler, which is still enough to miss the frame the user is waiting for.
What can arrive out of order
  • A ResizeObserver callback that writes a style affecting the observed size re-triggers observation; the browser breaks the loop by deferring the remaining notifications to the next frame and logging a warning, so the layout is one frame behind rather than incorrect.
  • Geometry measured before fonts or images have loaded describes a layout that no longer exists once they arrive. Measure after document.fonts.ready, or re-measure with an observer (Images and Fonts).
  • Two independent components measuring and writing in the same frame interleave with each other. Each is batched internally and together they thrash, which is why batching is a page-level discipline and not a component-level one.
Security
  • Precise layout measurement is a fingerprinting surface — resolved geometry varies with fonts, zoom, extensions and platform — which is why several precise timing and measurement paths have been coarsened over the years.
  • A forced layout whose cost scales with attacker-influenced content is a denial-of-service lever: content that makes each layout expensive turns a benign measuring loop into a page freeze (Intrinsic Sizing and the Automatic Minimum).
  • Nothing here is a boundary. Measuring is reading your own document, and the browser does not treat it as privileged.
Misreads
  • "Reading the DOM is slow." Reading is fast. Reading *after writing* is slow, because it forces the browser to finish work it had deferred.
  • "requestAnimationFrame fixes thrashing." It gives you a good place to do the work; it does nothing about interleaving. A read-write loop inside a rAF callback thrashes exactly as much.
  • "One forced layout is a bug." It is often necessary and usually fine. n of them in a loop is the bug.
  • "The framework protects me." Frameworks batch their own DOM writes. A ref callback, an effect or an event handler that measures runs synchronously in your code and forces layout exactly like anything else (What a Component Costs to Render).
  • "getComputedStyle is a cheap read." It forces style recalculation, and layout too for layout-dependent properties.
  • "The fix is fewer elements." Fewer elements makes each layout cheaper and leaves the loop running n of them. Batching removes n − 1 of them outright.

Measuring it, and what changes in the field

How you would see this
  • In the Performance panel, forced layouts appear as Layout events nested inside a script task, usually with a warning triangle and a "Forced reflow" or "Layout thrashing" annotation. Nesting is the signature: layout inside script rather than after it.
  • The same entries name the stack that triggered them, so you can find the exact property access — including one inside a dependency (Reading a Flame Graph).
  • Count them. Twenty Layout events inside one task is thrashing; one is a forced layout you may have needed.
  • Long-task and interaction instrumentation in the field tells you whether it matters to real users on real devices, which a local profile on a fast laptop will systematically understate (Real User Monitoring).
Slow device, slow network, large data, old tab
  • On a slow device the same loop costs several times more, because layout is main-thread CPU work and that is exactly where the device gap is widest.
  • With a large or deep document each forced layout traverses more boxes, so cost grows with the page rather than with the loop.
  • During an animation or a scroll it is at its worst: the stall lands inside the frame budget, so it is visible as a dropped frame rather than as a slow function (The Frame Budget).
  • With containment or content-visibility applied, the same loop can be dramatically cheaper without any change to the JavaScript — which also makes the problem harder to spot in a component that happens to be contained (content-visibility).
What this costs
  • Batching separates code that logically belongs together into two passes and needs an intermediate array, so it is less readable than the naive loop. That is the cost, and it is small.
  • Observers are asynchronous, so the value arrives a frame later than a synchronous read. Code that needed the number *now* has to be restructured to want it *soon*.
  • Caching geometry removes the reads and adds an invalidation problem: resize, zoom, font load, content change and orientation change all invalidate it, and each one you forget is a wrong layout rather than a slow one.
  • Containment makes forced layout cheap and constrains what the component can do — a contained element cannot size itself from content outside it (CSS Containment).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALLazy, batched layout with a synchronous flush on layout-dependent reads is how Blink, Gecko and WebKit all work; the CSSOM View specification requires these properties to return up-to-date values, which is what forces the flush in every engine.
  • BROWSER-SPECIFICDiagnosis is not portable: Chromium annotates forced reflows in the Performance panel with the triggering stack and a warning, Firefox surfaces reflow events in its profiler under different names and without the same annotation, and Safari shows layout events with no forced-reflow marker at all — so the same stall is obvious in one browser and nearly invisible in another.
  • SIMULATEDThe timeline in this lesson is generated by an Engineer Atlas model to show the shape of interleaved layout stalls in relative units. It is not a measurement, and the ratio between script and layout time varies enormously with document size and device class.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Computer Architecturetemporal-locality
Domains that do not exist yet
  • Programming Languages & Runtime Internals — the same shape appears wherever lazy work is forced by an eager read; the browser's dirty-bit layout is one instance of a very general pattern.