A quick refresher on INP
Interaction to Next Paint observes clicks, taps and key presses through a visit and reports a value near the slowest of them, ignoring outliers when there are many interactions. At the 75th percentile of page loads, 200 ms or less is good and more than 500 ms is poor (source 1). It has been a Core Web Vital since March 12, 2024 (source 2).
Each interaction has three phases (source 1):
- Input delay: from the user's action until event handlers start. Long when the main thread is busy with something else.
- Processing duration: the time event handlers take to run.
- Presentation delay: from the end of handlers until the next frame is painted, including style, layout and paint.
Knowing the dominant phase is half the diagnosis. Knowing which code fills it is the other half, and that is where Long Animation Frames come in.
From long tasks to long animation frames
The older Long Tasks API reports any task over 50 ms but says little about what ran inside it. The Long Animation Frames API, shipped in Chrome 123 after an origin trial, takes the user's point of view instead: it reports animation frames whose rendering update was delayed beyond 50 ms (source 3). A frame can be long because of one big task or several smaller ones that together block rendering, and LoAF captures both cases.
Each entry includes, among other fields (source 3):
duration: the length of the frame, excluding presentation.blockingDuration: how long the frame blocked input handling.renderStartandstyleAndLayoutStart: when rendering and style or layout work began, which split the frame into script work and rendering work.firstUIEventTimestamp: present when a UI event was processed in the frame, which marks frames tied to interactions.scripts: attribution entries for scripts that ran for more than 5 ms, each withinvokerandinvokerType(for example an event listener or arequestAnimationFramecallback),sourceURL,sourceFunctionNameandforcedStyleAndLayoutDuration.
The API is available in Chromium-based browsers; Firefox and Safari do not support it at the time of writing (source 3). That is acceptable for debugging, because the causes it reveals tend to be the same across browsers, but it means attribution data covers only part of your audience.
Observing long animation frames directly
You can watch LoAF entries in the console while you use a page:
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
const slowest = [...frame.scripts].sort((a, b) => b.duration - a.duration)[0];
console.log(
Math.round(frame.duration) + ' ms frame, blocking ' + Math.round(frame.blockingDuration) + ' ms',
slowest ? slowest.invoker + ' in ' + slowest.sourceURL : '(no script over 5 ms)'
);
}
}).observe({ type: 'long-animation-frame', buffered: true });
}The buffered: true option returns frames recorded before the observer started, up to a buffer of 200 entries (source 3). This is handy in development, but for real users you want LoAF data tied to the specific interaction that set the INP value.
Getting attribution from the web-vitals library
Linking a LoAF entry to an INP interaction means matching time ranges, which is fiddly. Google's web-vitals library does it for you. Its attribution build reports, for the INP interaction, the target element, the interaction type, the three phases, all events processed in the same frame and the long animation frames that overlapped it in longAnimationFrameEntries (source 4).
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
const frames = attribution.longAnimationFrameEntries || [];
const scripts = frames.flatMap((f) => [...f.scripts])
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map((s) => ({ invoker: s.invoker, source: s.sourceURL, fn: s.sourceFunctionName, ms: Math.round(s.duration) }));
sendToYourEndpoint({
metric: 'INP',
value: Math.round(value),
target: attribution.interactionTarget,
type: attribution.interactionType,
inputDelay: Math.round(attribution.inputDelay),
processing: Math.round(attribution.processingDuration),
presentation: Math.round(attribution.presentationDelay),
scripts
});
});By default, INP is reported when the page is hidden or closed, which suits analytics. While debugging locally, pass { reportAllChanges: true } to see each new slowest interaction as it happens (source 4). sendToYourEndpoint stands for whatever transport you use; our RUM pipeline guide covers sending and storing the data, including what not to collect.
Reading the data
Collect for long enough to see patterns, then group reports by interaction target and route. For each group, look at the median phases and the scripts that appear most often.
Input delay dominates
The main thread was busy when the user acted. Look at the scripts in the overlapping frames: they often belong to something other than the interaction's own handler.
- Hydration or start-up code, especially if the interactions happen soon after load. See the hydration guide.
- Timers and polling, such as a carousel tick or a data refresh.
- Third-party scripts, identifiable by their
sourceURL. Note that attribution for cross-origin iframes is limited (source 3).
Processing dominates
The interaction's own handlers do too much. The scripts will include your event listener as the invoker. Typical culprits are synchronous filtering of large lists, state updates that re-render large parts of the tree, and analytics calls made inside the handler. The common fix is to do the visible update first and defer the rest, which our long tasks guide covers.
Presentation delay dominates
The handlers finished, but drawing the next frame took a long time. Check forcedStyleAndLayoutDuration on the scripts: high values mean code read layout after changing the DOM, forcing synchronous layout. Otherwise look for very large DOM updates, expensive CSS selectors, or big style recalculations, and confirm in a trace.
Reproducing in DevTools
Attribution narrows the search; a trace confirms it. Open the Chrome DevTools Performance panel, set CPU throttling to match the devices in your data, and reproduce the interaction on the route and at the moment the data suggests, for example during the first seconds after load. In the trace, find the interaction, check its phases, and follow the call stacks into your code.
If the interaction is fast in DevTools but slow in the field, think about what differs: device speed, page state (a long list after scrolling, a cart with many items), browser extensions, or work that only happens for some users.
A worked example
Here is an invented case to show the reasoning. Attribution for a product listing page shows that most slow interactions target the "Sort by price" control. Median input delay is small, processing is about 380 ms and presentation about 90 ms. The top script in overlapping frames is an event listener in the listing bundle, with a sourceFunctionName of applySort.
The trace confirms that applySort sorts 2,000 items, rebuilds the list and logs an analytics event in one task. The fix plan: update the sort control's state and show a lightweight pending indicator immediately, yield, then sort and render the list, virtualize it so only visible rows render, and send the analytics event after the next paint. The numbers in this example are illustrative, but the pattern of reasoning is the one to follow.
Privacy note
Attribution fields can carry personal data. interactionTarget is a CSS selector that may include IDs or classes derived from user content, and sourceURL can include query strings. Truncate or sanitize these before sending, and reduce page URLs to route templates. See our privacy notes for RUM.
Sources
- Interaction to Next Paint (INP), web.dev Primary source (Google)
- Advancing Interaction to Next Paint, web.dev blog Primary source (Google)
- Long Animation Frames API, Chrome for Developers Primary source (Google)
- Measuring Interaction to Next Paint (INP) codelab, web.dev Primary source (Google)