Web Development · Performance
INP Is a Core Web Vital: What's Still Failing in 2026
Interaction to Next Paint replaced First Input Delay in 2024, and most sites still haven't caught up. Here's why INP is harder and how to actually fix it.
Anurag Verma
9 min read
Sponsored
In March 2024, Google replaced First Input Delay (FID), with Interaction to Next Paint (INP), as a Core Web Vital. FID had been easy to game, it only measured the delay before the browser started processing the first user interaction, not whether the result actually appeared on screen. INP is harder. It measures the full interaction: from when the user taps, clicks, or presses a key, to when the browser paints the visual response. And it takes the worst interaction in the session, not the first.
INP is one of three Core Web Vitals alongside LCP and CLS, and while image-related LCP work gets most of the optimization attention, INP is the one teams tend to leave unmeasured. Two years later, many sites are still failing it. The Chrome User Experience Report data for 2026 shows that around 35% of origins still have “Needs Improvement” or “Poor” INP scores. If you haven’t specifically optimized for INP, you probably have problems you haven’t found yet.
What INP Measures
INP captures three types of interactions: clicks, taps, and keyboard presses. It does not measure scrolling or hovering. For each interaction, it measures the time from input event to the next paint.
- Good: under 200ms
- Needs Improvement: 200-500ms
- Poor: over 500ms
The “worst interaction” rule has nuance. INP uses the 98th percentile of interaction durations in the session, which filters out one-off outliers without ignoring recurring problems.
An INP of 300ms means the 98th percentile interaction in your session takes 300ms to show visual feedback. A user clicking a button and waiting a third of a second to see anything is a noticeable delay.
Why It’s Harder Than FID
FID only asked: “When the user first clicked, how long did the browser wait before starting to process it?” Passing FID required not blocking the main thread during page load. One change (usually breaking up a large JS bundle or deferring non-critical code) could fix FID for most sites.
INP asks: “For every click, tap, or keypress throughout the session, how long until the visual update appears?” This includes:
- Interactions after heavy data loads
- Interactions that trigger React re-renders of large trees
- Keyboard interactions in input-heavy UIs
- Interactions that fire analytics or third-party scripts
The main thread blocking that FID missed (the kind that happens after page load, during user activity), is exactly what INP catches. Third-party script weight and protocol overhead both feed into this too; see our HTTP/3 and QUIC guide for the network side of the performance picture.
The Three Parts of an Interaction
An interaction’s duration is the sum of three phases:
Input delay: The time between the user’s input and when the browser starts processing the event handler. This is what FID measured. It’s high when the main thread is busy with something else, a long task running when the user clicks.
Processing time: How long your event handler takes to run. A click handler that does a lot of synchronous work (filtering a large array, building a DOM tree, computing a layout) has high processing time. The same handler takes proportionally longer on lower-end devices, which is one reason gating expensive features by device tier, using something like Chrome’s CPU Performance API, can improve INP specifically for that segment of users.
Presentation delay: The time between when your JavaScript finishes and when the browser actually paints. This phase is dominated by browser layout and rendering work, triggered when JavaScript changes the DOM in ways that require recalculation.
Total INP = input delay + processing time + presentation delay.
Optimizing INP means reducing whichever of these is the bottleneck for your worst interactions.
Finding Your INP Issues
The fastest tool for a quick diagnosis is the Chrome Performance panel. Record a session, interact with your page, and look for long tasks and layout thrashing in the main thread timeline.
For field data, CrUX (Chrome User Experience Report) has INP via PageSpeed Insights and Google Search Console. Field data is more useful than lab data for INP because it captures real user behavior patterns, including interactions that happen after the page settles.
Chrome’s web-vitals library reports INP in the browser:
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms')
console.log('Attribution:', metric.attribution)
// Send to your analytics
sendToAnalytics({
name: 'INP',
value: metric.value,
// metric.attribution tells you which interaction was the worst
elementTarget: metric.attribution.interactionTarget,
eventType: metric.attribution.interactionType,
})
})
The interactionTarget from attribution gives you the CSS selector for the element that caused the poor interaction. Combined with interactionType, you can pinpoint exactly what’s slow: “button.checkout-submit click” is more actionable than just knowing your INP is 450ms.
Common Causes and Fixes
Long Event Handlers
The most common INP problem: a click or input handler that does too much synchronous work.
// Slow: synchronous filter over a large list
searchInput.addEventListener('input', (e) => {
const query = e.target.value
const results = largeList.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
)
renderResults(results)
})
For work that’s genuinely CPU-heavy rather than just deferrable, moving it off the main thread entirely with a web worker removes it from the INP calculation altogether, since the main thread stays free to paint. For work that just needs to happen a beat later, defer it instead:
// Better: yield to the browser before the expensive work
let debounceTimer
searchInput.addEventListener('input', (e) => {
const query = e.target.value
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
const results = largeList.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
)
renderResults(results)
}, 16) // roughly one frame
})
For interactions that must be immediate, break processing into microtasks:
async function handleClick() {
updateUIImmediately() // First: update what the user can see now
// Yield to browser paint
await scheduler.yield()
// Then do expensive follow-up work
await expensiveUpdate()
}
scheduler.yield() is a relatively new browser API that yields to the browser’s task queue, allowing a paint before continuing. It’s supported in Chrome and has a polyfill via setTimeout(resolve, 0).
React Re-renders of Large Trees
React apps with large component trees can have high processing time for interactions that trigger re-renders. If you’re on Next.js 16 with the React Compiler, some of this gets handled automatically through compiler-inserted memoization, but it’s still worth understanding the manual patterns below for cases the compiler doesn’t cover.
Memoize expensive components so they don’t re-render when parent state changes:
// Without memo: re-renders whenever parent re-renders
function ExpensiveList({ items }) {
return <div>{items.map(item => <Item key={item.id} {...item} />)}</div>
}
// With memo: skips re-render when items hasn't changed
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
return <div>{items.map(item => <Item key={item.id} {...item} />)}</div>
})
Separate frequently-updated state from infrequently-updated state. If you have a modal that tracks whether it’s open and also contains a large list, moving the modal state closer to the button that toggles it prevents the list from re-rendering on open/close. A state management library like Zustand or Jotai makes this kind of scoping easier than prop-drilling through context.
Use useTransition for non-urgent updates in React 18+:
import { useTransition } from 'react'
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleSearch(e) {
const value = e.target.value
setQuery(value) // Urgent: update the input immediately
startTransition(() => {
// Non-urgent: update results in the background
// React can interrupt this if a more urgent update comes in
setResults(computeResults(value))
})
}
return (
<>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultList results={results} />}
</>
)
}
useTransition marks the results update as non-urgent. React renders the input update first (keeping the typing responsive), then renders the results. If the user keeps typing before results are ready, React cancels the in-progress results render and starts over with the new query.
Third-Party Scripts
Third-party analytics, chat widgets, and ad scripts are a significant source of INP problems. They run on the same main thread as your application code, and their long tasks block your event handlers.
The most reliable fix is loading them with type="module" or defer and prioritizing your own event handling:
<!-- Defer non-critical third-party scripts -->
<script defer src="https://example-analytics.com/tracker.js"></script>
For scripts that must run synchronously, consider loading them after setTimeout or using the Partytown library, built on the same web worker model, to move them off the main thread entirely.
Layout Thrashing
Presentation delay spikes when JavaScript reads and then writes to the DOM in alternating cycles, forcing the browser to recalculate layout repeatedly (thrashing).
// Bad: reads and writes interleaved = layout thrash
elements.forEach(el => {
const height = el.offsetHeight // Read: forces layout
el.style.height = height + 10 + 'px' // Write: invalidates layout
})
// Good: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight) // All reads first
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px' // All writes after
})
The browser’s DevTools Performance panel shows forced synchronous layout warnings when this is happening.
Prioritizing What to Fix
Not all INP problems are equally worth fixing. Focus on:
- High-traffic interactions: A slow checkout button affects more users than a slow settings toggle
- Interactions > 500ms: These are “Poor” INP scores and carry the most weight in Google’s ranking signal
- Interactions that feel broken: Users retry interactions they think failed, compounding the problem
The web-vitals attribution data showing interactionTarget in your analytics is the fastest path to prioritization. Once you’re tracking it, you’ll see which element and which interaction type is causing your worst scores in production. If you’re validating fixes under real traffic patterns, load testing with k6 can help confirm the backend isn’t also contributing to what looks like a purely front-end problem.
INP is harder to pass than FID because it watches the whole session. But the problems it catches (slow event handlers, heavy re-renders, main thread contention from third parties), are exactly the things users notice and complain about. Fixing them improves how the app feels, not just how it scores, and it’s part of the same technical SEO work for JavaScript-heavy apps that Core Web Vitals feed into more broadly.
Frequently asked questions
- What is INP and how is it different from FID?
- INP (Interaction to Next Paint) measures the full time from a user's click, tap, or keypress to when the browser visually updates, and it reports the worst interaction across the session using the 98th percentile. FID only measured how long the browser waited before starting to process the very first interaction, so it missed problems that only appeared after page load, once the user started interacting.
- What INP score counts as good?
- Google's thresholds are under 200ms for "Good," 200-500ms for "Needs Improvement," and over 500ms for "Poor." These are measured at the 75th percentile of page loads, using the 98th-percentile interaction within each session, so one occasional slow click won't tank your score, but a recurring one will.
- Why is my React app failing INP even though the page loads fast?
- Fast page load and fast interactions are measured separately. A React app can have a great LCP and still fail INP if clicks trigger re-renders of large component trees. Memoizing expensive components with React.memo, separating frequently-updated state from state that changes rarely, and wrapping non-urgent updates in useTransition are the standard fixes.
- How do I find which interactions are causing poor INP?
- Use the web-vitals library's onINP callback in production; its attribution data gives you the specific CSS selector and interaction type responsible for the worst interaction, like "button.checkout-submit click." For local debugging, Chrome's Performance panel shows long tasks and layout thrashing directly on the main thread timeline.
- Do third-party scripts really affect INP that much?
- Yes. Analytics, chat widgets, and ad scripts run on the same main thread as your own code, and a long task inside one of them blocks your event handlers from running on time. Loading them with defer or type="module", or moving them to a web worker with a library like Partytown, is usually the fastest fix available.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored