INP Is the Core Web Vital That Punishes Your JavaScript: How to Find the Slow Interaction and Fix It
A page can load in under a second, score green on LCP, never shift a pixel, and still feel broken. You tap "Add to cart", nothing happens for half a second, you tap again, and now there are two items in the basket.
That half second has a name. Interaction to Next Paint (INP) became a Core Web Vital in March 2024, replacing First Input Delay, and it is the metric most teams discover only when Search Console starts flagging URLs as "Poor". FID only measured the delay before the browser started handling the first interaction. INP measures the whole thing, for every interaction on the page, and reports one of the worst.
The thresholds, evaluated at the 75th percentile of real visits:
- INP โค 200 ms good
- 200 ms < INP โค 500 ms needs improvement
- INP > 500 ms poor
What INP is actually timing
Every interaction INP measures has three parts:
- Input delay: the time between the tap and the moment your event handler starts running. Usually caused by something else already occupying the main thread.
- Processing duration: the time your handlers take to run.
- Presentation delay: the time between the end of your handlers and the next frame actually being painted, which includes style recalculation, layout and paint.
The important consequence: a fast click handler can still produce a bad INP if the main thread was busy when the tap arrived, or if the handler triggers an expensive re-render. You can't fix INP by looking only at your own function.
Step 1: catch it in the console
Chrome already tells you when a handler is too slow, if you let it. In DevTools, open the Console, set the log level to include Verbose, and interact with the page. On a struggling product page you'll see lines like these:
[Violation] 'click' handler took 486ms
[Violation] Forced reflow while executing JavaScript took 74ms
[Violation] 'input' handler took 212ms
These aren't errors and nothing breaks, which is exactly why nobody notices them. The first line is a click handler doing almost half a second of work. The second is a classic layout thrash, covered below.
Step 2: get real attribution with the web-vitals library
Console violations show you something is slow. To know which interaction is your INP and which part of it is the problem, use Google's web-vitals library with its attribution build:
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
console.log('INP', value, rating, {
target: attribution.interactionTarget,
type: attribution.interactionType,
inputDelay: Math.round(attribution.inputDelay),
processing: Math.round(attribution.processingDuration),
presentation: Math.round(attribution.presentationDelay),
});
}, { reportAllChanges: true });
On the page from the example above, clicking the add-to-cart button logs:
INP 568 poor { target: 'button#add-to-cart', type: 'pointer', inputDelay: 12, processing: 498, presentation: 58 }
Now the problem has an address. Input delay is tiny, so the main thread wasn't blocked by something else. Presentation is modest. Processing is 498 ms: the handlers attached to that button are the problem.
In production you'd send this object to your analytics instead of logging it, so you see which interactions fail for real users on real devices, which are usually far slower than your laptop.
Step 3: confirm in the Performance panel
The Performance panel in recent versions of Chrome shows live Core Web Vitals as you interact with the page, including INP and the list of interactions that produced it. Record a trace, perform the slow click, and look at the main thread track.
A failing interaction typically looks like one long yellow block of scripting under the click event, often with a purple "Layout" block hanging off the end. Expanding the call tree usually reveals the culprit within a minute.
In this example it was three things running synchronously inside one click: updating the cart badge, re-rendering a recommendations carousel, and sending two analytics events, each of which serialised a large object.
Fix 1: do the visible part first, the rest later
The user needs to see that the click worked. They don't need the analytics call or the recommendations refresh to finish before that happens. Split the handler: update the UI immediately, then yield to the browser so it can paint, then do the rest.
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
button.addEventListener('click', async () => {
// 1. the part the user is waiting for
cart.add(product);
badge.textContent = cart.count;
button.textContent = 'Added';
// 2. let the browser paint that change
await yieldToMain();
// 3. everything else
refreshRecommendations(product);
trackAddToCart(product);
});
scheduler.yield() is available in current Chromium-based browsers. The setTimeout fallback gives a similar effect elsewhere.
After this change, the same interaction logs:
INP 136 good { target: 'button#add-to-cart', type: 'pointer', inputDelay: 9, processing: 71, presentation: 56 }
Same work, same total cost. The difference is that the user sees the response before the expensive part runs.
Fix 2: stop forcing layout in a loop
That Forced reflow violation from earlier is worth its own section, because it's one of the most common hidden costs in interaction handlers. It happens when code alternates between writing styles and reading layout values:
// Slow: each read forces the browser to recalculate layout
items.forEach((el) => {
el.style.height = 'auto';
const h = el.offsetHeight; // read: forces layout
el.style.height = h + 'px'; // write: invalidates layout again
});
Every offsetHeight read after a style change forces the browser to compute layout immediately, and the next write invalidates it again. With fifty items, that's fifty layouts in a single frame.
Batch the reads, then the writes:
items.forEach((el) => {
el.style.height = 'auto';
});
const heights = items.map((el) => el.offsetHeight); // one layout
items.forEach((el, i) => {
el.style.height = heights[i] + 'px';
});
Same result, one layout instead of fifty, and the violation disappears from the console.
Fix 3: don't run heavy work on every keystroke
Search fields and filters are the second most common INP offender. A handler that filters a large list on every input event repeats the expensive work for every character typed, and each keystroke is an interaction that INP measures.
let timer;
searchInput.addEventListener('input', (e) => {
clearTimeout(timer);
const query = e.target.value;
timer = setTimeout(() => renderResults(filter(query)), 150);
});
The field itself stays responsive because typing only updates the input, and the expensive filtering runs once the user pauses. For very large lists, also consider rendering only the visible rows instead of thousands of DOM nodes at once.
Fix 4: watch out for third-party scripts
Sometimes your handler is fine and the input delay is the problem. In attribution data, that looks like this:
INP 412 needs-improvement { target: 'a.menu-toggle', type: 'pointer', inputDelay: 341, processing: 18, presentation: 53 }
An 18 ms handler waited 341 ms to start. Something else owned the main thread when the visitor tapped, very often a chat widget, an A/B testing script or a tag manager container firing on page load.
The fix is not in your code: it's loading those scripts later, loading fewer of them, or moving their heavy work off the critical moments when people are most likely to interact, such as the first few seconds after load.
Checking the result where it counts
Lab tools confirm the fix on your machine. Field data confirms it for your visitors. The Core Web Vitals report in Google Search Console groups URLs by status, and a typical before-and-after on a product template looks like this:
Mobile ยท INP issue: longer than 500ms (mobile) Status: Poor URLs: 214 Example: /product/linen-shirt-blue ...
Mobile ยท INP issue: longer than 200ms (mobile) Status: Validation started URLs: 214
Two things to know before celebrating. First, the report is based on the Chrome User Experience Report, which aggregates the previous 28 days of real visits, so a fix shipped today takes weeks to fully show up. Second, Search Console groups similar pages together, so fixing the template that all product pages share moves hundreds of URLs at once, while fixing a single page barely registers.
Start validation from the report after deploying, and keep your own field logging running so you don't have to wait a month to know whether it worked.
What to take away
INP rewards a simple discipline: when a person interacts with your page, show them the result first, and do everything else afterwards. Measure with field data, not just lab runs, because the slow interactions happen on mid-range phones you don't test on. And read the console with verbose logging switched on from time to time. The browser has been warning you about slow handlers for years. Most of us just never looked.
Written by Sara Casciaro, founder of Sabriel Agency, digital studio in Ugento (LE), Italy.
Comments
No comments yet. Start the discussion.