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.
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,
forcedStyleAndLayoutDurationon 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.
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.
// 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.
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.
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.
: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.
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;textContentdoes 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.
Related
- Finding forced reflows in the Performance panel — a deeper DevTools walkthrough.
- Batching DOM reads and writes — patterns and libraries for read/write scheduling.
- Reducing presentation delay from large DOM updates — when layout cost lands after the handler instead.