Layout Thrashing & Forced Reflow: When JavaScript Makes Layout Run Again and Again
This topic is part of Rendering & CSS Performance. Browsers are lazy about layout in a useful way. When JavaScript changes styles or the DOM, the browser marks layout as dirty and waits; it will compute layout once, just before the next frame is painted, no matter how many changes happened in between. This batching is why changing a hundred elements' styles in a loop is normally cheap.
The batching breaks the moment JavaScript asks a question that requires up-to-date layout: an element's offsetHeight, getBoundingClientRect(), scrollTop, clientWidth, or getComputedStyle() for a layout-dependent property. If layout is dirty, the browser must compute it immediately, synchronously, before the script can continue. This is a forced reflow (or forced synchronous layout). One forced reflow is often harmless. Many — a write, then a read, then a write, then a read, in a loop — make layout run once per iteration. This is layout thrashing, and on large pages it can turn a 2ms operation into a 200ms long task.
Layout thrashing is one of the most common causes of slow interactions. It hides in tooltips and popovers that position themselves, accordions that measure content height, masonry grids, sticky headers that read scroll position, text-fitting utilities, drag-and-drop libraries, and third-party widgets that measure their containers. Because each read looks innocent in isolation, thrashing often goes unnoticed until INP data highlights it.
The Metric Degradation This Topic Addresses
Forced reflows and thrashing increase the duration of the tasks they occur in. During page load, that adds to Total Blocking Time and can delay hydration. During interactions, it increases the processing time of event handlers, which directly increases INP. Thrashing inside scroll or resize handlers causes dropped frames and janky scrolling, and thrashing inside animation loops causes stutter.
In the Performance panel, forced reflows appear as purple Layout blocks inside script execution (nested under a function call), often with a red triangle and the warning "Forced reflow is a likely performance bottleneck". Each one carries a stack trace showing which line of code read layout. Thrashing looks like a comb: many thin Layout blocks alternating with script.
Prerequisites
- A Performance panel recording of the slow interaction or load phase, with 4x CPU throttling.
- Source maps, so stack traces for forced reflows point at your source code.
- Field INP data with attribution (from the web-vitals library or Long Animation Frames), to prioritise which interactions to fix.
1. Environment Setup: Reproduce the Slow Interaction
Identify the interactions with the worst INP from field data — for example, opening a filter panel, expanding a product description, or typing in a search box. Reproduce each in the lab with CPU throttling, and record a Performance trace that includes the interaction.
2. Capture a Baseline
For each interaction, record total task duration, time inside Layout events, and number of Layout events. A handler that triggers 50 layouts of 3ms each is the classic thrashing signature.
3. Isolate the Reads That Force Layout
Click each forced reflow warning to see the stack trace. Common culprits are measurement calls inside loops, utility functions that read and write in one go, and library code. Note which reads happen after writes in the same task.
// Typical thrash: each iteration writes then reads.
for (const card of cards) {
card.style.height = 'auto'; // write: layout now dirty
const h = card.offsetHeight; // read: forced layout
card.style.height = `${Math.ceil(h / 8) * 8}px`; // write again
}
// trade-off: the code is easy to read but runs layout once per card; on 200 cards
// with a complex page this can take hundreds of milliseconds.
4. Apply: Separate Reads From Writes
Restructure into a read phase and a write phase. Read all geometry first (one layout at most), then apply all writes (layout happens once, at the next frame). Where code is spread across modules, a scheduling helper such as fastdom, or a simple queue run in requestAnimationFrame, enforces the separation.
// Read phase: one layout.
for (const card of cards) card.style.height = 'auto';
const heights = cards.map((c) => c.offsetHeight);
// Write phase: no reads, so no forced layout.
cards.forEach((c, i) => { c.style.height = `${Math.ceil(heights[i] / 8) * 8}px`; });
// trade-off: the 'auto' reset is itself a write before the reads; grouping all
// resets first keeps it to one forced layout instead of one per card.
Deconstructing What Forces Layout
Two kinds of operations interact. Writes invalidate layout: changing styles that affect geometry (width, height, padding, font size, display), adding or removing elements, changing text content, toggling classes. Reads require valid layout: geometric properties (offset*, client*, scroll*), getBoundingClientRect(), getClientRects(), getComputedStyle() for layout-dependent values, innerText (which depends on layout), focus() (which may scroll), and scrollIntoView(). Also window.innerHeight in some cases, and elementFromPoint().
A forced reflow happens only when a read follows a write and layout is dirty. Reading many properties in a row after one write forces a single layout; the subsequent reads use the fresh layout. This is why batching works: all reads first, then all writes, then let the browser lay out once at the next frame.
The cost of each forced layout depends on how much of the page is dirty and how large the DOM is. On a small page, a forced reflow might cost a fraction of a millisecond; on a page with 10,000 elements and no containment, several milliseconds. Containment reduces the scope of each layout, so it makes forced reflows cheaper even when they cannot be avoided entirely.
Advanced Diagnostics and Edge Cases
Third-party code. Analytics, A/B testing and chat widgets sometimes measure the page repeatedly. The stack trace identifies them; options include configuration, delaying their initialisation, or replacing them.
Framework lifecycle hooks. Reading layout in useLayoutEffect, mounted or ngAfterViewInit after other components have written styles in the same commit forces layout; batching measurements in one place helps.
Scroll handlers. Reading scrollY is cheap if layout is clean, but if the handler also writes styles each time, every subsequent scroll event forces layout. Use passive listeners, requestAnimationFrame and IntersectionObserver.
Resize handlers. Measuring elements on every resize event, then writing, thrashes during window resizing; ResizeObserver delivers sizes after layout instead.
Text measurement. Fitting text by repeatedly adjusting font size and measuring is a thrash loop; use canvas measureText or CSS (clamp(), container query units) instead.
Validation and Budgeting
After changes, re-record the interaction: the forced reflow warnings should be gone or reduced to one, and Layout time inside script should drop sharply. In the field, track INP for the affected interaction targets. A useful lab budget is "no forced reflow warnings in key interactions", enforced by a Lighthouse user-flow script or a Puppeteer trace check.
| Check | Target |
|---|---|
| Forced reflow warnings in key interactions | 0–1 |
| Layout events per interaction | 1–2 |
| INP for affected targets (p75) | < 200 ms |
| Scroll frame drops (lab) | None visible |
Worked Example: A Product Comparison Table
An electronics retailer's comparison page equalised row heights across columns with a script: for each of 60 specs, it reset heights, measured each of up to 6 product cells, and set the maximum. Opening the comparison or adding a product triggered a 340ms task with 360 forced layouts on a mid-range phone; INP p75 for the "add to compare" button was 420ms. Rewriting the logic as a read phase (reset all heights, then measure all cells) and a write phase reduced the task to 45ms with one forced layout. Later, the team replaced the script entirely with CSS grid and subgrid, which aligns rows natively, removing the measurement altogether. INP p75 dropped to 150ms.
Replacing Measurement With CSS
The best forced reflow is the one you no longer need. Many measurement scripts exist because CSS once lacked a feature that it now has. Equal-height rows and aligned card content: CSS grid with subgrid. Animating to height: auto: interpolate-size: allow-keywords or the grid-template-rows 0fr/1fr technique. Sticky positioning: position: sticky instead of scroll handlers. Responsive components that adapt to their container: container queries instead of measuring width. Text that fits: clamp() and container query units. Positioning popovers next to anchors: CSS anchor positioning, where supported. Each removes a class of read-write loops.
Using Observers Instead of Polling
When measurement is necessary, observers deliver it at the right moment. ResizeObserver reports element sizes after layout has been computed and before paint, so reading the reported sizes never forces a layout. IntersectionObserver reports visibility changes asynchronously without reading geometry on the main thread in a loop. Replacing resize and scroll event handlers that measure with these observers is often the single change that fixes janky scrolling and resizing.
Forced Style Recalculation: The Lesser-Known Sibling
Not every forced synchronous operation is layout. Calling getComputedStyle(el).color or reading a CSS custom property after changing classes forces a style recalculation without a full layout. In the Performance panel these appear as Recalculate Style events nested inside script, again with a warning. They are usually cheaper than forced layouts, but on pages with very large DOMs or broad invalidations — such as toggling a theme class on body — a single forced style recalculation can take tens of milliseconds. The same discipline applies: read computed values before making changes, or cache them, and avoid reading styles inside loops that also toggle classes.
Thrashing in Third-Party and Legacy Code
Some of the worst thrashing is in code you did not write: jQuery plugins that set heights in loops, carousel libraries that measure every slide on each transition, analytics scripts that check element visibility by reading geometry on scroll, and A/B testing tools that wait for elements by polling their size. You cannot restructure their internals, but you can control when and how often they run. Initialise them after the critical interactions are ready, limit them to the pages that need them, and prefer configuration options that disable automatic measurement. Where a widget is both heavy and thrash-prone, an iframe isolates its layout entirely, because each document lays out independently — at the cost of more memory and less integration.
When evaluating a new library, record a trace of its typical interaction on a throttled device before adopting it. A library that triggers dozens of forced reflows per interaction will drag INP down on every page it is used on, and the problem only grows as pages get bigger.
Team Practices That Prevent Thrashing
Thrashing is easiest to prevent at code review time. Agree on a few conventions: geometry reads go through a small set of helpers (or a read/write scheduler), components never measure themselves in a loop over siblings, and new scroll or resize listeners require a note explaining why an observer or CSS cannot do the job. Add an ESLint rule or code search check for offsetHeight, getBoundingClientRect and similar calls inside loops in files that also set style. These small habits stop most regressions before they ship. They cost almost nothing to follow and save hours of profiling later.
A Rollout Plan
- Rank interactions by field INP and identify those with forced reflow warnings in the lab.
- For each, find the reading code from the stack trace.
- Replace with CSS where a modern feature exists.
- Otherwise, separate reads and writes, or move measurements into observers.
- Re-record and compare; ship and watch INP for the targets.
- Add a lab check that fails if key interactions produce forced reflow warnings.
Common Pitfalls
- Reading layout in loops after writes. The textbook thrash.
- Hiding reads in helpers. Utilities like
isVisible(el)that callgetBoundingClientRect()are easy to call inside write loops. - Measuring on scroll and resize events. Use observers.
- Assuming frameworks prevent thrashing. They batch DOM writes, but your effects and third-party libraries can still read after write.
- Ignoring third-party code. Widgets can thrash without any of your code involved.
FAQ
What is layout thrashing?
Repeatedly forcing the browser to recompute layout by alternating style writes and geometry reads in the same task, so layout runs many times instead of once.
Which properties force layout?
Geometric reads such as offsetWidth, clientHeight, scrollTop, getBoundingClientRect(), getComputedStyle() for layout values, innerText, and methods like scrollIntoView() and focus() when layout is dirty.
Is reading layout always bad?
No. Reading when layout is already clean (for example at the start of a frame, before any writes) is cheap. The problem is reading after writes, repeatedly.
Does requestAnimationFrame prevent forced reflow?
Not by itself. It schedules work before the next paint; if that work writes then reads, it still forces layout. Use it to group writes after reads have been done.
How do I find forced reflows?
Record a Performance trace and look for Layout events nested inside script with the "Forced reflow" warning; the event's stack trace shows the reading code.
Do frameworks like React cause layout thrashing?
Framework rendering itself batches DOM writes, but code in layout effects, refs and third-party components can read layout after writes. Profile to find out.
Can containment reduce the cost of forced reflow?
Yes. If the dirty area is inside a contained subtree, the forced layout is limited to that subtree, which makes each reflow cheaper.
Guides in This Topic
- Finding forced reflows in the Performance panel — locate the reading code.
- Batching DOM reads and writes — restructure code to lay out once.
- ResizeObserver vs resize and scroll handlers — measure without forcing layout.
Related
- Rendering & CSS performance — the full rendering pipeline.
- CSS containment & content-visibility — reduce the cost of each layout.
- Profiling event handlers for INP — the metric thrashing hurts most.