Interaction to Next Paint (INP) became an official Core Web Vital on 12 March 2024, replacing First Input Delay. FID was a lie of omission – it only measured the delay before the *first* interaction, and it did so in milliseconds so generous that 95% of the web passed. INP measures every interaction on the page and reports the worst (technically the 98th percentile once you cross ~50 interactions). Most sites that passed FID fail INP. Ours did too, on the first audit.
This is the 30-day plan we run for clients when their Core Web Vitals dashboard turns yellow. It assumes one engineer, part-time, and a real production site – not a greenfield rewrite.
What INP actually measures
INP is the time from when a user taps, clicks, or presses a key to when the browser paints the next frame reflecting that interaction. It captures three things FID never did: (1) the input delay, (2) the processing time of your event handlers, and (3) the presentation delay while the browser renders. Google's thresholds are 200ms (good), 500ms (needs improvement), and above 500ms (poor).
The p75 across all real users of the page – from CrUX field data – is what shows up in Search Console. Lab tools like Lighthouse do not measure INP the same way, because Lighthouse does not interact with the page. Use Lighthouse for TBT (Total Blocking Time) as a proxy, but trust CrUX for the truth.
Finding the worst pages
Start with data, not intuition. Pull the CrUX report from Search Console → Core Web Vitals → Mobile → INP issues. Group URLs by template, not by URL – a slow product template drags 10,000 URLs down at once. Fixing one template fixes them all.
Cross-reference with your own field data. If you do not already collect INP in production, install a `PerformanceObserver` today. It takes ten minutes and it is the only way to see which specific interaction on which specific page is blowing the budget.
// Report the worst INP interaction per page to your analytics.
// Runs in every modern browser. Attach once, on every page.
let worstINP = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only durations > 40ms are reported by the browser.
if (entry.interactionId && entry.duration > worstINP) {
worstINP = entry.duration
// Debounce – send on pagehide, not on every interaction.
}
}
})
observer.observe({ type: 'event', durationThreshold: 40, buffered: true })
addEventListener('pagehide', () => {
if (worstINP > 0) {
navigator.sendBeacon('/api/metrics/inp', JSON.stringify({
inp: Math.round(worstINP),
url: location.pathname,
// Add: connection type, device memory, user agent segment.
}))
}
}, { once: true })The four things that actually cause bad INP
After auditing dozens of sites, almost every INP failure comes down to one of four blockers. Fix these in order – do not skip ahead to the exotic ones until the boring ones are done.
| Blocker | Symptom | Fix |
|---|---|---|
| Long tasks on the main thread | INP > 500ms on click, especially on lower-end Android | Break work with scheduler.yield() or setTimeout(fn, 0). Move JSON.parse and heavy loops to a Web Worker. |
| React hydration | First interaction after page load is slow, later ones are fine | Ship less client JS. Use React Server Components. Defer non-critical islands with dynamic import + client-only wrapper. |
| Third-party scripts | INP degrades over the page lifetime as tag manager loads more | Load with strategy="lazyOnload" in next/script. Kill any tag not tied to revenue. Move analytics to server-side. |
| Uncontrolled re-renders | Typing in a search box feels laggy after a few characters | Debounce input to 300ms before firing state changes. Move filter logic off the render path. React.memo list items. |
| Large synchronous DOM updates | Opening a modal or dropdown paints slowly | Use CSS content-visibility. Virtualise long lists. Split the update across two frames. |
The 30-day plan
Four weeks, one focus per week. Do not do them in parallel – you will lose the ability to attribute wins to specific changes.
Week 1 – Measurement
- Ship the PerformanceObserver snippet above to production. No optimisation yet – you need a baseline.
- Wire the beacon endpoint to store `{ url, inp, deviceCategory, connection }` for at least 7 days.
- Pull CrUX INP by template from Search Console. Rank templates by (traffic × p75 INP) to prioritise.
- Pick the top three templates. Everything else waits.
Week 2 – Third-party audit
- List every third-party script in <head> and via GTM. For each, name the business owner and revenue tied to it.
- Kill anything without a named owner. Kill anything whose owner cannot state a KPI it moves.
- Move surviving scripts to lazyOnload or server-side (Google Tag Manager server container, Segment server-side destinations).
- Re-measure. You should see 20-40% of your INP problem disappear on high-tag pages.
Week 3 – Main thread work
- Profile the worst interaction on the worst template using Chrome DevTools Performance panel (CPU throttle: 4x, network: Slow 4G).
- Identify long tasks over 50ms. Break them with scheduler.yield() where available, setTimeout(fn, 0) as fallback.
- Move any JSON.parse over 100kB to a Web Worker. Same for chart libraries doing layout maths.
- For React: convert leaf components to Server Components where they do not need interactivity. Ship less JS.
Week 4 – Interaction-specific fixes
- Search boxes and filter inputs: debounce state updates to 300ms. Never fire a network request on keystroke.
- Modals and dropdowns: pre-render content with content-visibility: auto so the paint on open is cheap.
- Long lists: virtualise anything over 50 rows with @tanstack/react-virtual.
- Retest field data 7 days after deploy. p75 INP under 200ms is the target – anything under 300ms is a real win.
Common mistakes
- Optimising Lighthouse instead of CrUX. Lighthouse does not interact – a 100 score can coexist with a p75 INP of 800ms.
- Fixing the homepage first. Homepages usually have low interaction density. Product, search, and checkout templates matter more.
- Assuming React 19 fixed it. Concurrent rendering helps some cases but hydration is still the number one blocker on content-heavy React sites.
- Trusting synthetic tests. WebPageTest with a scripted click helps for reproduction, but real users interact differently.
What to do next
Install the PerformanceObserver today – before you optimise anything. Then open your Search Console → Core Web Vitals report, rank templates by traffic × p75 INP, and pick the top three. If you want the four-week plan tracked against real field data, our site audit tool ingests CrUX + your own RUM beacon and shows template-level INP trends day by day.
