INP: what it is, why it replaced FID and how to improve it
Understand Interaction to Next Paint (INP), why it replaced FID in Core Web Vitals, how to find slow interactions and what to fix in your JavaScript to improve it.
By Rafael Coelho · Full-stack developer and creator of Analisa
Published 8 min read
You tap "Add to cart" and nothing happens for half a second. You tap again. Now there are two items in the cart. That "frozen site" feeling has a name and a number: INP, the responsiveness metric in Core Web Vitals since March 2024.
Here I explain what INP measures, why it replaced FID, how to find the slow interactions on your site and what to change in your JavaScript to improve it — with code.
What INP is
INP (Interaction to Next Paint) measures how long a page takes to show visual feedback after someone interacts with it. "Interacting" here means clicking, tapping the screen or pressing a key. Hovering and scrolling don't count.
The clock starts at the interaction and stops when the browser paints the next frame. That time has three parts:
- Input delay: the time until the browser can start running your event listeners. If the main thread is busy with something else, the interaction waits in line.
- Processing duration: the time spent running the event handlers — your
onClick,onKeyDowncode and so on. - Presentation delay: the time between the handlers finishing and the frame appearing: recalculating styles, layout, painting.
INP looks at every interaction during the visit, not just one. The final value is, in practice, the slowest interaction; on pages with lots of interactions, the metric ignores the worst one for every 50 so that a single outlier doesn't dominate. If the person never interacted with the page, there's no INP for that visit.
What a good INP is
| Rating | INP |
|---|---|
| Good | 200 ms or less |
| Needs improvement | 200 ms to 500 ms |
| Poor | over 500 ms |
The value that counts is the 75th percentile of real visits, with mobile and desktop measured separately. In other words, to pass, three out of four visits need an INP of 200 ms or less. The average won't do — it hides exactly the part of your audience on slower phones, who suffer the most.
Why INP replaced FID
FID (First Input Delay) measured just one thing: the input delay of the first interaction. It ignored how long your code took to process the click, ignored painting and ignored everything after the first interaction. A site could have a great FID and a search filter that froze the screen for a second.
INP covers the whole cycle (input, processing and presentation) across all interactions. That's why Google made the switch: on March 12, 2024, INP became a Core Web Vital in place of FID. In September 2024, Chrome's tools, such as CrUX and PageSpeed Insights, stopped reporting FID.
Field vs lab: why Lighthouse doesn't show INP
INP depends on real interaction. An automated test that only loads the page doesn't click anything, so it can't measure INP. That's why it's worth keeping the two data sources apart:
- Field data: real visits. This is what Google uses to assess your site's Core Web Vitals. It comes from CrUX (the Chrome User Experience Report, covering the last 28 days of Chrome users) or from a RUM tool installed on your site.
- Lab data: a controlled test, like Lighthouse. In a standard Lighthouse report, the closest thing to INP is TBT (Total Blocking Time): how long the main thread was blocked during loading. High TBT usually goes hand in hand with poor INP, because long tasks are the number one cause of input delay.
The Web Vitals checker shows both for any public page: field INP from CrUX (for the URL or, when there isn't enough data, for the whole origin) and the Lighthouse score with TBT, on mobile or desktop.
CrUX only has data for sites with enough traffic from Chrome. For smaller sites, or to look page by page, you need to measure on your own site. Analisa's SDK collects Web Vitals automatically (LCP, FCP, CLS, INP and TTFB), and the Web Vitals report in the dashboard (at /reports/web-vitals) shows the p75 of each metric, its rating (good, needs improvement, poor), the number of samples and the worst-performing pages.
How to find the slow interactions
1. Find out which pages suffer
Start with field data: which pages have a p75 INP above 200 ms? There are usually only a few, and almost always the most interactive ones — filtered search, cart, long forms, dashboards.
2. Find out which interaction
Knowing that the search page has a 450 ms INP doesn't tell you which click is the problem. The attribution build of the web-vitals library, from the Chrome team, tells you which element received the interaction and where the time went. You can send that as an event to Analisa and see it in the events report:
import { onINP } from 'web-vitals/attribution'
onINP(({ value, rating, attribution }) => {
if (rating === 'good') return // we only care about the slow ones
window.analytics.track('slow_inp', {
value_ms: Math.round(value),
element: attribution.interactionTarget, // CSS selector of the element
type: attribution.interactionType, // 'pointer' or 'keyboard'
input_delay_ms: Math.round(attribution.inputDelay),
processing_ms: Math.round(attribution.processingDuration),
presentation_ms: Math.round(attribution.presentationDelay),
})
})Whichever part dominates tells you where to look: high input delay points to other tasks hogging the thread (third-party scripts, hydration, timers); high processing points to your handler; high presentation points to a large DOM or expensive rendering.
3. Reproduce it in the lab
Once you've identified the interaction, open Chrome DevTools, Performance tab:
- Turn on CPU throttling (4x or 6x) to simulate a typical phone. Your laptop is too fast to reproduce the problem.
- Record, perform the slow interaction and stop recording.
- Find the interaction in the Interactions track and the long tasks (marked with a red triangle) on the main thread. The flame chart shows which function spent the time.
In recent versions of Chrome, the Performance panel itself shows your local INP live as you click around the page, no recording needed.
How to improve INP
1. Break up long tasks and yield to the browser
Any JavaScript task longer than 50 ms counts as a long task. While it runs, the browser can't respond to anything. The fix is to split the work and, between chunks, yield the main thread so the browser can handle interactions and paint.
function yieldToMain() {
// scheduler.yield() resumes the work with priority; fall back to setTimeout
if (globalThis.scheduler?.yield) return scheduler.yield()
return new Promise((resolve) => setTimeout(resolve, 0))
}
async function processItems(items) {
let start = performance.now()
for (const item of items) {
process(item)
// only yield once ~50 ms have passed, so you don't pay the cost on every item
if (performance.now() - start > 50) {
await yieldToMain()
start = performance.now()
}
}
}scheduler.yield() has been available in Chrome and Edge since version 129 and in Firefox since 142; Safari doesn't support it yet, hence the setTimeout fallback.
2. Show the response first, do the rest later
INP stops at the next painted frame. So the question is: what needs to be on screen right now? Update the UI (button in a loading state, item checked) and push the rest — saving, syncing, sending analytics — until after you've yielded the thread:
button.addEventListener('click', async () => {
button.disabled = true
button.textContent = 'Adding…' // this needs to show up right away
await yieldToMain() // let the browser paint the frame
updateCart() // the heavier work
sendEvents() // and whatever isn't urgent
})If you use React, useTransition does something similar for state updates: it marks the expensive update as non-urgent, and React keeps the UI responsive while it computes.
3. Debounce whatever fires on every keystroke
A search filter that recalculates the whole list on every letter typed is a poor-INP factory. Update the input immediately and wait for the person to stop typing before doing the work:
function debounce(fn, ms) {
let t
return (...args) => {
clearTimeout(t)
t = setTimeout(() => fn(...args), ms)
}
}
const filter = debounce((term) => renderResults(search(term)), 200)
searchInput.addEventListener('input', (e) => filter(e.target.value))4. Avoid layout thrashing
Reading a layout measurement (offsetHeight, getBoundingClientRect()) right after changing a style forces the browser to recalculate layout immediately, synchronously. In a loop, that multiplies the cost:
// Bad: interleaved reads and writes — one forced layout per item
for (const card of cards) {
card.style.height = `${card.firstElementChild.offsetHeight}px`
}
// Better: read everything first, then write everything — a single layout
const heights = cards.map((card) => card.firstElementChild.offsetHeight)
cards.forEach((card, i) => {
card.style.height = `${heights[i]}px`
})5. Ship less JavaScript
Less code means fewer long tasks, especially during load, when people are already trying to click:
- Cut third-party scripts you no longer use (chat widgets, heatmaps, forgotten pixels). Each one competes for the same main thread.
- Split your bundle and lazy-load what isn't on the first screen (modals, editors, charts).
- Watch out for hydration in SSR frameworks: the page looks ready but doesn't respond until the JavaScript finishes hydrating. Fewer interactive components above the fold help.
6. Reduce rendering work
If presentation delay dominates, the problem is the size of what the browser has to recalculate. A DOM with thousands of nodes, huge lists rendered all at once and very complex CSS weigh on every interaction. Virtualize long lists and use content-visibility: auto on off-screen sections so the browser can skip rendering them until they become visible.
Checklist
- Check field INP for your main pages (CrUX in the Web Vitals checker or Analisa's Web Vitals report).
- On pages above 200 ms, use
web-vitalsattribution to find the interaction and the part (input, processing or presentation) that dominates. - Reproduce it in DevTools with CPU throttling.
- Break up long tasks with
yieldToMain, give feedback before heavy work, debounce inputs. - Separate layout reads from writes.
- Cut JavaScript and third parties that don't pay for themselves.
- Measure again after deploying — CrUX takes up to 28 days to fully reflect the change; RUM on your own site shows it sooner.
If you don't measure Web Vitals from real users yet, Analisa collects LCP, INP, CLS, FCP and TTFB automatically as soon as the script is installed — the getting started guide shows how, and there are guides for Next.js and other stacks.
Sources
- Interaction to Next Paint (INP) — web.dev
- Interaction to Next Paint becomes a Core Web Vital on March 12 — web.dev
- Optimize Interaction to Next Paint — web.dev
- Optimize long tasks — web.dev
- Use scheduler.yield() to break up long tasks — Chrome for Developers
- Find slow interactions in the field — web.dev
- First Input Delay (FID) — web.dev
- web-vitals — upgrading to v4 (INP attribution) — GoogleChrome/web-vitals (GitHub)
- Scheduler: yield() method — MDN Web Docs
See where your customers come from — without cookies
Analisa shows where each visit comes from (referrer and UTM), how many convert and where your funnel leaks, with IPs anonymized at ingest. Free up to 10K events a month.
Try first: Web Vitals checker
Related tools and pages
- Web Vitals checkerReal-user LCP, INP and CLS (CrUX) plus a Lighthouse lab score for any public page, mobile or desktop.
- Getting startedSign up, create your first app, and start sending events to Analisa in under 5 minutes.
- Next.jsHow to add cookieless analytics to Next.js: a client component in the App Router layout, _app for the Pages Router, events, goals and route changes.

About the author
Rafael Coelho
Full-stack developer and creator of Analisa. Writes about programming, product and data — and measures everything with Analisa itself.
See all articles