Optimizing INP: The Core Web Vital That Replaced FID

Photo by Nicola since 1972 on flickr
Interaction to Next Paint measures the latency of every click, tap, and keyboard interaction during a page visit and reports the worst one at the seventy fifth percentile. First Input Delay only measured the delay before the very first interaction was handled, so it missed slowness that happened later in a session once state and DOM size had grown.
Google rates an INP of 200 milliseconds or less as good, 200 to 500 milliseconds as needs improvement, and anything above 500 milliseconds as poor. These thresholds apply at the seventy fifth percentile of real visits, segmented by mobile and desktop.
The main thread can only run one task at a time, so a script that runs for more than fifty milliseconds without pausing blocks input handling and painting. If that long task happens to run during or right after a user interaction, the browser cannot respond until the task finishes, which directly inflates INP.
The scheduler.yield method pauses execution and places the continuation at the front of the task queue, so it resumes as soon as any higher priority input handling completes. It shipped stable in Chrome 129 and is now supported by all major browsers except Safari, so feature-detect it and fall back to a setTimeout-based yield where it is unavailable.
Yes. Google's open source web-vitals library listens to the Event Timing API and reports INP directly from real sessions, including attribution data that identifies the DOM element and event type responsible for the slowest interaction. Lab tools like Lighthouse only simulate a handful of scripted interactions and cannot substitute for field data.

Photo by Nicola since 1972 on flickr
Key Takeaway
Interaction to Next Paint (INP) is the Core Web Vital that replaced First Input Delay in 2024, scoring a page's slowest interaction instead of just its first click. Fixing a poor INP score requires breaking up long JavaScript tasks, yielding to the main thread, and measuring real users with the web-vitals library rather than relying on lab tests alone.
First Input Delay only ever measured one thing: how long the browser waited before it could start handling the very first click, tap, or key press on a page. It quietly retired in March 2024, replaced by Interaction to Next Paint, a Core Web Vital that watches every interaction across the entire visit and reports the slowest one that still counts after outliers are trimmed. If your team spent years chasing a good FID score only to watch INP turn red in Search Console, this is why: FID never cared what happened after the first tap, and most real slowness happens later, deep into a session, once state, listeners, and DOM nodes have piled up.
This post walks through what INP actually measures, why long JavaScript tasks are almost always the root cause of a poor score, and the concrete techniques that bring INP back under the two hundred millisecond good threshold: breaking up long tasks, yielding to the main thread with modern scheduling APIs, trimming unnecessary work inside event handlers, and instrumenting real users with the web-vitals library so you are optimizing against actual field data instead of a single lab trace.
INP looks at every click, tap, and keyboard interaction that occurs during a page visit and calculates a latency value for each one, made up of three phases: input delay before the event handler starts running, processing time while the handler executes, and presentation delay until the browser paints the next frame. INP then reports whichever interaction was slowest after a small number of statistical outliers are discarded on interaction-heavy pages, evaluated at the seventy fifth percentile across real visits, split by mobile and desktop. A page with one sluggish dropdown buried on scroll can still fail INP even if every other click feels instant, because the metric is designed to catch the worst experience a typical visitor encounters, not the average one.
| Rating | 75th Percentile Threshold |
|---|---|
| Good | 200 milliseconds or less |
| Needs Improvement | Between 200 and 500 milliseconds |
| Poor | Over 500 milliseconds |
The browser's main thread can only do one thing at a time. Layout, style recalculation, JavaScript execution, and painting all compete for the same thread, so any script that runs uninterrupted for more than fifty milliseconds is classified as a long task and blocks everything else, including the click handler a user is waiting on. Long tasks creep in through a handful of common culprits on typical Next.js and React applications.
Open Chrome DevTools, record a Performance trace while clicking through your slowest interaction, and look for the red-flagged long task bars under the Main track. The INP breakdown in the newer Performance Insights panel will label each phase directly, so you rarely need to guess which part of the interaction is the bottleneck.
The fix for a long task is rarely to make the work itself faster; it is to chop the work into smaller pieces and hand control back to the browser between pieces so it can paint, run input handlers, and stay responsive. This pattern is called yielding. Historically developers faked it with setTimeout, which defers a callback to the end of the task queue, but that approach lets lower priority work jump ahead of what the user is waiting on. The scheduler.yield method, which shipped stable in Chrome 129 in September 2024 and is now supported in Chromium-based and Firefox browsers, solves that by pausing execution and placing the continuation at the front of the queue, so your code resumes as soon as any higher priority input handling finishes, rather than waiting behind every other queued timer.
// Feature-detect scheduler.yield(), fall back to a microtask-friendly helper
function yieldToMain() {
if ('scheduler' in window && 'yield' in window.scheduler) {
return window.scheduler.yield()
}
// Fallback: postMessage-based macrotask yield (setTimeout(0) is throttled)
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function processLargeList(items, renderRow) {
for (let i = 0; i < items.length; i++) {
renderRow(items[i])
// Yield every N items instead of every item, or every frame budget (~5ms)
if (i % 20 === 0) {
await yieldToMain()
}
}
}
// In an interaction handler: paint feedback first, then do heavy work
button.addEventListener('click', async () => {
showSpinner() // cheap, paints immediately
await yieldToMain() // let the browser paint the spinner
await processLargeList(rows, renderRow)
hideSpinner()
})When you are refactoring an interaction that trips a long task, work through these steps in order rather than reaching for scheduler.yield everywhere by default, since yielding too often adds its own overhead.
Do not read layout-triggering DOM properties such as offsetHeight or getBoundingClientRect immediately after writing a style change inside the same task. That combination forces a synchronous layout recalculation, known as layout thrashing, which silently adds to processing time and will not show up as a single obvious long task, just cumulative slowness across many interactions.
Lab tools like Lighthouse simulate a handful of scripted interactions on a single machine, which is useful for catching regressions in CI but tells you nothing about how your actual visitors, on their actual devices and networks, experience responsiveness. Google's open source web-vitals library listens to the same Event Timing API that Chrome uses internally and reports INP directly from real sessions, including attribution data that names the DOM target and event type responsible for the slowest interaction, letting you go straight from a dashboard alert to the exact component causing it.
import { onINP } from 'web-vitals'
onINP((metric) => {
// metric.value is in milliseconds, metric.rating is
// 'good' | 'needs-improvement' | 'poor'
const target = metric.attribution?.interactionTarget
const eventType = metric.attribution?.interactionType
navigator.sendBeacon('/analytics/web-vitals', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
target,
eventType,
id: metric.id,
}))
}, { reportAllChanges: false })You do not need a custom analytics pipeline to start tracking INP in production. A small integration layered onto whatever you already use for page views is enough to start finding real regressions within days.
Teams that pair scheduler.yield for main-thread work with a Web Worker for genuinely heavy computation typically see the biggest INP wins with the least code churn, because neither technique requires restructuring how state flows through the application.