How to Fix INP When Presentation Delay, Not Your Handler, Is the Problem

This guide uses the rendering timestamps of the Long Animation Frames API to diagnose the third and least understood phase of INP, within Core Web Vitals & Measurement.

The scenario: your INP breakdown says input delay is 12ms, processing is 35ms, and yet the interaction totals 240ms. Almost 200ms is presentation delay — the time from your last listener returning to the browser painting the result. Yielding inside the handler cannot help because the handler is already done. The cost is in style recalculation, layout, paint and compositing for whatever your handler changed, and LoAF is the only field API that splits that tail apart.

A fast handler with a slow render tail Timeline of an interaction where the handler finishes at 47 milliseconds but style, layout and paint push the next frame to 240 milliseconds. A fast handler with a slow render tail Interaction handler presentation delay LoAF tail rAF style + layout paint + composite 0ms 50ms 100ms 150ms 200ms 250ms INP 200ms

Rapid Diagnosis

  • Compute the render tail. For the INP frame, startTime + duration − renderStart is time spent rendering after scripts. Over 50ms is a problem; over 100ms is the problem.
  • Split style/layout from the rest. renderStart marks the start of the rendering phase and styleAndLayoutStart comes after requestAnimationFrame callbacks, so styleAndLayoutStart − renderStart is rAF work and the remainder of the frame is style, layout and paint.
  • Check the Performance panel's "Recalculate Style" event. Its "Elements affected" count tells you whether the change touched 40 nodes or 40,000.
  • Look for "Layout" with a large node count and "Paint" events covering the whole viewport after a small UI change.
  • Toggle Rendering → Paint flashing in DevTools and repeat the interaction. Whole-screen green flashes mean the paint invalidation was far larger than the visual change.

Root Cause Analysis

1. Broad style invalidation. Toggling a class on <html> or <body> (a theme switch, a "menu open" state) forces the engine to re-match selectors for every element under it. With descendant selectors depending on that class, style recalculation scales with DOM size rather than the size of the change.

2. Unbounded layout. Inserting content into a flow without containment can force layout for the whole document — every following sibling moves. A filter that swaps 300 product cards lays out the page, the footer, and anything positioned relative to them.

3. Large paint and raster areas. A change that triggers repaint of a full-viewport element with box-shadows, gradients, or filter: blur() is expensive on low-end GPUs. The script is cheap; the pixels are not.

4. Synchronous requestAnimationFrame work. Code scheduled with requestAnimationFrame runs inside the rendering phase of the same frame, before style. Analytics or measurement code that "politely" waits for rAF still delays the paint.

Render-tail cause to signal and fix Four causes of presentation delay with the DevTools signal that identifies each and the CSS or JavaScript fix. Render-tail cause to signal and fix Cause DevTools signal Fix Broad style invalidation Recalculate Style: 10k+ elements Scope the class lower in the tree Unbounded layout Layout root is #document contain: layout or content-visibility Large paint area full-screen paint flashing Promote or simplify the effect rAF work in the frame Animation Frame Fired before style Move to post-paint callback

Step-by-Step Resolution

1. Measure the tail per interaction target in the field

Extend your INP beacon with the render-tail split so you can find which targets suffer from rendering cost at scale.

javascript
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
  const f = attribution.longAnimationFrameEntries?.at(-1);
  if (!f) return;
  const end = f.startTime + f.duration;
  report({
    inp: Math.round(value),
    target: attribution.interactionTarget,
    rafMs: Math.round(Math.max(0, f.styleAndLayoutStart - f.renderStart)),
    renderMs: Math.round(end - f.styleAndLayoutStart),
  });
});
// trade-off: using the LAST overlapping frame assumes the interaction's paint
// happens there. Interactions that schedule a second frame (rAF → rAF) need
// the frame containing the presentation timestamp instead.

Expected outcome: a ranked list of interaction targets where renderMs dominates — typically two or three components.

2. Narrow the style invalidation scope

Move state classes from the document root to the smallest subtree that needs them, and avoid selectors that make large parts of the tree depend on a root class.

css
/* Before: every rule under .menu-open must be re-matched across the page. */
.menu-open .site-header nav a { color: var(--accent); }

/* After: the state lives on the component; invalidation stays inside it. */
.site-header[data-menu="open"] nav a { color: var(--accent); }
/* trade-off: component-scoped state makes global "page dimmed while menu open"
   effects harder; use a single overlay element for those instead of a root class. */

Expected outcome: "Elements affected" in Recalculate Style drops from thousands to tens; render tail typically falls 30–90ms.

3. Contain layout for the region that changes

css
.results-grid {
  contain: layout paint;        /* changes inside cannot move content outside */
}
.results-grid > .card {
  content-visibility: auto;     /* off-screen cards skip rendering entirely */
  contain-intrinsic-size: auto 320px;
}
/* trade-off: contain: paint clips overflow (dropdowns, tooltips escaping the
   grid). Portal those overlays to the body or skip paint containment. */

Expected outcome: the Layout event's root changes from #document to .results-grid, and layout time scales with visible cards only. See CSS containment and content-visibility for the full model.

4. Move post-interaction work after the paint

Anything that does not change what the user sees — analytics, logging, prefetching — should run after the next paint, not in a rAF callback before it.

javascript
function afterNextPaint(fn) {
  requestAnimationFrame(() => setTimeout(fn, 0));   // rAF → task after the frame
}
button.addEventListener('click', () => {
  applyFilter();                                 // visible change: do it now
  afterNextPaint(() => track('filter_applied'));  // invisible work: after paint
});
// trade-off: work deferred past the paint can be lost if the page unloads in
// the same instant. For must-send analytics, queue a beacon instead.

Expected outcome: the rAF portion of the tail disappears from LoAF entries for the affected interaction.

Render tail before and after each fix (mid-tier phone) Bar chart of render tail duration after cumulatively applying three fixes, ending under 50 milliseconds. Render tail before and after each fix (mid-tier phone) Baseline 193ms Scoped state class 121ms + layout containment 64ms + work after paint 41ms 50ms tail target

Verification

In the lab, record the interaction with paint flashing on and confirm the flashed area matches the visual change. In the trace, Recalculate Style should report a small element count and Layout should be rooted at the contained element. In the field, the renderMs series for the target should drop and INP's presentation-delay share should fall below a third. If presentation delay is still high but the trace looks lean, check for a heavy backdrop-filter or large composited layers — the guide on will-change overuse and layer explosion covers that case.

Edge Cases That Hide in the Render Tail

Font swaps landing in the interaction frame. If an interaction reveals text in a weight or family that has not loaded yet — a bold label in an opened accordion, an icon font in a menu — the font request starts on that frame and the swap triggers a second layout later. The first frame is not slow, but the visible result is not final either. Preload the weights used by interactive UI, or render those states with already-loaded faces.

Image decode on reveal. Expanding a panel that contains large images can push synchronous decode into the paint step. Large images revealed by interaction should carry decoding="async" and, ideally, be decoded ahead of time with img.decode() when the user hovers the trigger.

Sticky and fixed elements during scroll-linked updates. A handler that updates something inside a position: sticky header can force the browser to repaint the header on every frame of an ongoing scroll. LoAF shows a modest paint each time, but on low-end GPUs these add up to a slow render tail for any interaction that happens mid-scroll.

Transitions started by the same interaction. A CSS transition on height or top that the click starts will not lengthen the interaction frame itself, but each subsequent frame does layout. The next interaction may land on one of those frames and inherit its cost as input delay. Transition transform and opacity instead, and the follow-on frames stay on the compositor.

When none of these apply and the tail is still long, take a trace with the "Enable advanced paint instrumentation" setting on. It is slower to record, but the paint profiler lists draw operations per layer, which is the only way to see that a single large box-shadow or filter is what costs 60ms on a mid-range phone.

FAQ

Why does presentation delay vary so much between devices?

Style and layout scale with CPU speed, but paint and raster also scale with GPU capability and screen resolution. A high-DPR phone with a weak GPU can spend three times longer rasterising the same change than a laptop. That is why lab traces on desktop routinely under-report presentation delay; throttle the CPU and test on a real mid-tier Android device.

Can I yield inside rendering?

No — rendering is the browser's work, not a task you control. What you can do is make the change smaller (fewer nodes, narrower invalidation), split a large visual update across frames deliberately (render the first screenful now, the rest after paint), or skip rendering for off-screen content with content-visibility.