Fixing slow interactions: a practical guide to INP
Interaction to Next Paint replaced First Input Delay as a Core Web Vital in 2024, and it’s a much harder metric to game. FID only measured the delay before your event handler started; INP measures the time from the user’s interaction to the next frame the browser paints afterwards. If someone taps a button and the UI freezes for 600 milliseconds while your JavaScript churns, FID might have reported 5 milliseconds. INP reports the 600.
I’ve spent a fair amount of time recently digging slow interactions out of production apps. This post covers how I find them and the fixes that actually move the metric.
The anatomy of an interaction
Every interaction INP measures breaks down into three phases:
- Input delay - the time between the user interacting and your event handler starting. Usually this means the main thread was busy doing something else.
- Processing duration - the time your event handlers take to run. This is the phase most people think of as “the slow bit”.
- Presentation delay - the time from your handlers finishing to the browser painting the next frame. Layout, style recalculation and paint all live here.
The distinction matters because the fix is different for each phase. Optimising your click handler won’t help if the problem is a long task from a third-party script delaying the input, and neither will help if you’re forcing a synchronous layout afterwards.
Finding the slow interactions
Lab tools are a poor fit for INP because the metric depends on what real users actually do. Lighthouse can’t know that your users hammer the filter dropdown on the search page. The place to start is field data, and the web-vitals library’s attribution build tells you exactly where the pain is:
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
navigator.sendBeacon('/analytics', JSON.stringify({
value,
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay
}));
});
The interactionTarget is a CSS selector for the element the user interacted with. Aggregate a week of this and you get a ranked list of your worst interactions along with which phase is responsible. It’s a very short path from “our INP is 380ms” to “the sort button on the results page spends 300ms in processing”.
Once you know which interaction to look at, then the lab is useful: record a session in the Performance panel, perform the interaction and look at what’s occupying the main thread.
Fixing processing duration: yield before you work
The most common pattern I find is an event handler doing everything synchronously: update some state, recompute something expensive, render. The browser can’t paint until the handler returns, so the user stares at a dead button.
The fix is to run the urgent part - the visual feedback - and defer the expensive part until after the next paint:
button.addEventListener('click', async () => {
setPending(button);
await scheduler.yield();
const results = expensiveFilter(data);
render(results);
});
scheduler.yield() breaks the work into two tasks: everything before the await runs in the handler, the browser gets a chance to paint the pending state, and the expensive work continues afterwards - at the front of the task queue, so it isn’t stuck behind whatever else is waiting. It’s supported in Chromium and Firefox at the time of writing; for Safari you’ll want a fallback:
const yieldToMain = () => {
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
};
The user still waits for their results, but the page responds immediately. INP measures the next paint, not the completion of the work, and more importantly that’s what makes the page feel alive rather than broken.
Fixing input delay: break up long tasks
If attribution shows high input delay, your handler isn’t the problem - something else was hogging the main thread when the user interacted. The Performance panel will show you a long task (anything over 50 milliseconds gets the red triangle) sitting between the input and your handler.
If the long task is your own code - hydration, data processing, rendering a big list - the same yielding technique applies, just inside the loop:
const processItems = async (items) => {
for (const [index, item] of items.entries()) {
process(item);
if (index % 50 === 0) {
await yieldToMain();
}
}
};
Each yield point is a gap where a pending interaction can jump in. If the long task belongs to a third-party script, your options are blunter: load it later, load it in a worker via something like Partytown, or have the conversation about whether it’s worth what it costs.
Fixing presentation delay: stop thrashing layout
The sneakiest phase. Your handler is fast, nothing is blocking the input, and yet the frame takes an age to arrive. The usual culprit is forced synchronous layout: reading a layout property like offsetHeight after you’ve written to the DOM, forcing the browser to recalculate styles mid-task, often repeatedly in a loop.
The fix is to batch your reads before your writes, or let something like requestAnimationFrame structure it for you. Large DOM sizes amplify everything here - if a style recalculation touches 10,000 nodes, no amount of JavaScript optimisation will save you. content-visibility: auto on off-screen sections is a cheap win for long pages.
Start here
- Ship the attribution snippet first. You can’t fix what you can’t see, and guessing which interactions are slow is a good way to spend a sprint optimising the wrong thing.
- Fix the visual feedback before the actual work. Moving from “frozen for 400ms” to “responds instantly, results in 400ms” is the same amount of computation and a completely different experience.
- Profile on a cheap phone, or throttle. Your M-series laptop is lying to you. A 4x CPU throttle in DevTools is the honest baseline.
INP rewards exactly the thing users notice: does the page respond when I touch it? That makes it my favourite of the Core Web Vitals - the work you do to improve the number is rarely wasted on the metric alone.