How to Fix INP Caused by Forced Layout Inside Event Handlers

This guide covers a specific processing-time problem within Profiling Event Handlers for INP, part of Core Web Vitals & Measurement: handlers that are slow not because of their own logic but because they force the browser to compute layout synchronously, sometimes dozens of times.

Browsers batch style and layout work and perform it once per frame. When JavaScript writes to the DOM (adding a class, changing a style, inserting a node) and then reads a layout-dependent property (offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle() for layout values), the browser must perform layout immediately to return a correct answer. Do that once and it costs a few milliseconds. Do it in a loop — write, read, write, read — and each iteration forces a fresh layout: layout thrashing. A handler that measures 50 accordion panels this way can spend 150ms in forced layout while its own logic takes 5ms.

Interleaved vs batched DOM access Comparison of a loop that alternates DOM writes and layout reads, forcing layout on every iteration, with a batched version that forces layout once. Interleaved vs batched DOM access Interleaved (thrashing) • write class on item 1, read its height • write class on item 2, read its height • ... 50 forced layouts • Handler: 160ms, mostly layout Batched (one layout) • read all 50 heights first • then write all 50 classes • 1 layout, at the next frame • Handler: 12ms

Rapid Diagnosis

  • Look for purple inside yellow. In the Performance panel, forced layouts appear as purple "Layout" (and "Recalculate Style") blocks nested inside your JavaScript call stacks, marked with a red warning triangle: "Forced reflow is a likely performance bottleneck".
  • Click the warning. The summary shows the JavaScript line that triggered the layout — the read — and links to the source.
  • Count the layouts per interaction. More than one or two Layout events inside a single handler indicates thrashing.
  • Check LoAF attribution. If you collect Long Animation Frames data, forcedStyleAndLayoutDuration on the handler's script entry quantifies the problem directly.
  • Check third-party code. Analytics and A/B tools that read element positions on click (for heatmaps or visibility checks) can force layout inside your interaction.

Root Cause Analysis

1. Measure-then-mutate loops. Code that iterates elements, changes each, and reads its new size — common in accordions, masonry layouts, "equal height" scripts and tooltip positioning.

2. Reading layout after a framework update. Calling a state setter and then synchronously reading the DOM (via a ref) in the same handler. In frameworks that flush synchronously, the read forces layout of the freshly mutated tree.

3. Scroll position reads after insertion. Inserting content and immediately reading scrollHeight or scrollTop to auto-scroll a chat or log view.

4. getComputedStyle in hot paths. Reading computed values like width or height forces layout; even reading non-layout values forces style recalculation.

Time inside one accordion "expand all" handler Bar chart of a click handler's time split into script logic and forced layout, before and after batching reads and writes. Time inside one accordion "expand all" handler Logic (before) 6ms Forced layout (before) 154ms Logic (after) 6ms Forced layout (after) 4ms 50ms

Step-by-Step Resolution

1. Separate reads from writes

Gather every measurement first, then apply every mutation. One layout happens at the next frame instead of one per iteration.

javascript
// Before: read and write interleaved — forces layout each iteration.
panels.forEach((p) => { p.classList.add('open'); p.style.height = p.scrollHeight + 'px'; });

// After: all reads, then all writes.
const heights = panels.map((p) => p.querySelector('.inner').scrollHeight);  // reads (1 layout)
panels.forEach((p, i) => { p.classList.add('open'); p.style.height = heights[i] + 'px'; });  // writes
// trade-off: reads now happen before the 'open' class is applied, so they must
// not depend on it. Measure an inner element whose size does not change with
// the open state, or measure once on load and cache.

Expected outcome: forced layout inside the handler drops to at most one; processing duration falls by most of the forced-layout time.

2. Defer reads to the next frame when they do not need to be synchronous

If a measurement only drives a follow-up visual adjustment, read it in requestAnimationFrame — layout will already have happened for the frame, so the read is free.

javascript
button.addEventListener('click', () => {
  list.append(newItem);                                   // write
  requestAnimationFrame(() => {                           // read on the next frame
    list.scrollTop = list.scrollHeight;                   // still a write, but layout is fresh
  });
});
// trade-off: the follow-up adjustment lands one frame later. For auto-scrolling
// that is invisible; for positioning a tooltip it can flash in the wrong place
// for a frame — hide it until positioned.

Expected outcome: the read no longer forces layout inside the interaction.

3. Use observers instead of polling layout

ResizeObserver and IntersectionObserver deliver sizes and visibility after layout, asynchronously, with no forced reflow. Replace "measure on click" patterns with cached values kept fresh by observers.

javascript
const sizes = new WeakMap();
const ro = new ResizeObserver((entries) => {
  for (const e of entries) sizes.set(e.target, e.contentBoxSize[0].blockSize);
});
panels.forEach((p) => ro.observe(p.querySelector('.inner')));
// In the handler: sizes.get(inner) — no layout read at all.
// trade-off: cached sizes can be one frame stale after a change. For layout
// that must be pixel-exact at the moment of the click, read once, batched.

Expected outcome: handlers do no layout reads; see ResizeObserver vs resize and scroll handlers.

4. Prefer CSS over measured JavaScript animation

Many measure-then-animate patterns exist only to animate height. CSS grid-template-rows: 0fr → 1fr or interpolate-size: allow-keywords with height: auto animate to intrinsic size without any JavaScript measurement.

css
:root { interpolate-size: allow-keywords; }        /* Chromium 129+ */
.panel { height: 0; overflow: clip; transition: height 200ms; }
.panel.open { height: auto; }
/* trade-off: interpolate-size is not yet supported in every engine; elsewhere
   the panel snaps open without animation, which is an acceptable fallback. */

Expected outcome: the handler only toggles a class; all layout happens once, in the browser's normal frame.

Forced layouts inside a single click handler Timeline of a click handler with repeated forced layout blocks interleaved with short script segments, compared with the batched version. Forced layouts inside a single click handler Thrashing layout layout layout ... x50 Batched 0ms 20ms 40ms 60ms 80ms 100ms 120ms 140ms 160ms

Verification

Record the interaction again: the red forced-reflow warnings should be gone, and the handler's flame chart should contain at most one small Layout block. Processing duration in the Interactions track should fall correspondingly. Add a lint rule or code review checklist item for reads after writes in handlers; there is no reliable automated detection, but traces in CI (with LoAF's forcedStyleAndLayoutDuration asserted below a few milliseconds for scripted interactions) catch regressions.

Properties That Force Layout

A practical list of reads to watch for inside handlers, all of which force style and/or layout if the DOM has pending changes:

  • Element geometry: offsetTop/Left/Width/Height, clientTop/Left/Width/Height, scrollTop/Left/Width/Height, getClientRects(), getBoundingClientRect().
  • Scrolling: scrollBy(), scrollTo(), scrollIntoView(), focus() (which may scroll).
  • Computed style: getComputedStyle() and reading its layout-dependent properties.
  • Window: innerWidth/innerHeight, scrollX/scrollY, getSelection() in some cases.
  • Text: innerText (which depends on layout; textContent does not).

The last one is a frequent surprise: reading innerText to get an element's text forces layout, while textContent does not. Swapping them in a hot path is often a free win.

How do I find forced layouts caused by third-party scripts?

In the trace, expand the call stack above the purple Layout block; the top frames show which script's function read layout. Vendor scripts that measure elements on click — heatmaps, scroll-depth trackers, visibility checks for ad viewability — are common culprits. You cannot change their code, but you can delay their initialisation, configure them to sample fewer elements, or load them only on a subset of sessions.

FAQ

Is one forced layout per interaction acceptable?

Usually. A single forced layout costs roughly what the next frame's layout would have cost anyway — the work moves earlier rather than doubling. The problem is repetition: every additional write-then-read cycle adds another full layout.

Do frameworks prevent layout thrashing?

Frameworks batch their own DOM writes into a commit, which avoids thrashing between framework updates. They cannot prevent your code — or a library's — from reading layout in an effect or handler between writes. useLayoutEffect in React is a common source, because it runs synchronously after the DOM is mutated: any measurement there forces layout.

Does reading layout in a requestAnimationFrame callback ever force layout?

It can, if you write first. Inside a rAF callback, style and layout for the coming frame have not run yet; a read before any write in that frame uses the previous frame's layout and is cheap, but a write followed by a read in the same callback forces layout exactly as it would in an event handler. Keep the same discipline everywhere: reads first, then writes.