Fixing Poor Interaction to Next Paint (INP)
Interaction to Next Paint measures how long a page takes to show a visible response after someone clicks, taps or presses a key. When it is poor, menus open late, "add to basket" feels stuck and typed characters lag behind the keyboard.
Google counts INP as good at 200 milliseconds or less at the 75th percentile of real visits, and phone users on mid-range hardware feel the problem most. This guide shows how to find the exact interaction that is slow, work out which of its three phases is to blame, and apply the fix that matches that phase.
What INP actually measures
Every time a visitor clicks, taps or presses a key, the browser has to run whatever JavaScript is listening for that input and then draw a new frame showing the result. INP is the time from the input to that next frame. Hovering and scrolling are not counted; only discrete interactions are.
The browser records every qualifying interaction during the whole visit, not just the first. When the visitor leaves, the page's INP is the slowest of those interactions. On pages where people interact a great deal, one outlier is ignored for every 50 interactions, so a single freak delay on a long session does not dominate. Google then takes the 75th percentile of those per-visit values across all real Chrome users over 28 days.
| INP at the 75th percentile | Rating | What the visitor feels |
|---|---|---|
| 200 ms or less | Good | The page reacts almost at once. |
| Over 200 ms up to 500 ms | Needs improvement | A noticeable pause; people start tapping twice. |
| Over 500 ms | Poor | The page feels frozen or broken. |
The three phases of one interaction
Each interaction breaks into three parts, and the fix depends entirely on which one is large:
- Input delay. The wait before your event handler even starts, because the main thread was busy with something else: a script still loading, a timer firing, an analytics tag doing its work.
- Processing duration. The time your own handlers for that input take to run, including every listener attached to the element and its ancestors.
- Presentation delay. The time after the handlers finish until the frame is painted: style recalculation, layout, paint and compositing of whatever changed.
A menu that opens slowly because a chat widget was busy has an input-delay problem. A filter that
re-sorts 2,000 products synchronously has a processing problem. A button that toggles a class on the
<body> and forces the browser to restyle a DOM of 6,000 nodes has a presentation problem.
Treating all three the same way wastes time.
The usual causes, by phase
| Phase | Typical cause | Where it comes from |
|---|---|---|
| Input delay | Long tasks already running when the visitor taps | Large bundles still
executing after load, hydration in JavaScript frameworks, tag managers firing many tags, consent platforms,
session-recording scripts, setInterval timers |
| Input delay | Several listeners queued on the same event | Analytics click tracking, heatmap tools and A/B testing scripts all listening to every click |
| Processing | Heavy synchronous work in the handler | Filtering or sorting large arrays,
building big chunks of HTML as strings, JSON parsing, synchronous localStorage access in
loops |
| Processing | Framework re-rendering too much | A state change high in a component tree that re-renders the whole page instead of the one widget that changed |
| Processing | Forced synchronous layout | Reading offsetHeight or getBoundingClientRect()
straight after writing styles, often inside a loop |
| Presentation | Very large DOM | Mega-menus rendered in full on every page, long product grids, comment threads, hidden tab content that is still in the DOM |
| Presentation | Expensive style changes | Toggling a class high in the tree that invalidates
styles across the page, heavy box-shadow or filter effects repainted on every
frame |
Third-party scripts turn up in more than one row. They add long tasks that cause input delay, and some attach their own click listeners, which adds to processing time for every tap on the page.
How to find the interaction that fails
INP is a field metric. Lighthouse cannot measure it because nobody clicks anything during a lab run; it shows Total Blocking Time instead, which hints at input delay during load but says nothing about a slow handler on a filter button. You need data from real visitors first, then a lab session to reproduce what they did.
Start from the field
The Core Web Vitals checker shows the field INP for the URL and the origin from the Chrome UX Report, on mobile and desktop. The Search Console Core Web Vitals report groups URLs with the same problem, which usually points to one template. Neither tells you which button was slow, so the next step is to collect that yourself.
Collect attribution from your own visitors
Google's open-source web-vitals library has an attribution build that reports, for the
interaction that set INP, the element that was interacted with, the type of input and the length of each
phase. Send it to your analytics and you will see which elements fail most often.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
const a = attribution;
sendToAnalytics({
metric: 'INP',
value: Math.round(value),
rating, // 'good' | 'needs-improvement' | 'poor'
target: a.interactionTarget, // a CSS selector for the element
type: a.interactionType, // 'pointer' or 'keyboard'
inputDelay: Math.round(a.inputDelay),
processing: Math.round(a.processingDuration),
presentation: Math.round(a.presentationDelay),
loadState: a.loadState // was the page still loading?
});
});
A few days of data is usually enough. Sort by target and look for the selectors that appear again
and again with a poor rating. A loadState of loading or dom-interactive
means people tapped before the page settled, which points at input delay from start-up scripts rather
than at the handler.
Reproduce it in DevTools
Open Chrome DevTools, go to the Performance panel and set CPU throttling to a 4x or 6x slowdown so a desktop behaves more like a mid-range phone. Recent versions of Chrome show live interaction timings in this panel as you click, which is a quick way to spot the slow control. For detail, start a recording, perform the interaction, stop the recording and find the interaction in the Interactions track. Hovering over it shows the input delay, processing duration and presentation delay. Tasks with a red corner in the Main track are long tasks over 50 milliseconds; expand them to see which script and which function ran.
Code patterns that fix each phase
Yield to the main thread inside long work
The single most useful technique is to split one long task into several short ones so the browser
can paint and handle other input in between. scheduler.yield() does this in Chromium-based
browsers; older browsers can fall back to a zero-length timeout.
function yieldToMain() {
if (globalThis.scheduler && typeof scheduler.yield === 'function') {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
async function applyFilters(products, filters) {
const results = [];
for (let i = 0; i < products.length; i++) {
if (matches(products[i], filters)) results.push(products[i]);
if (i % 200 === 0) await yieldToMain(); // let the browser breathe
}
return results;
}
Show feedback first, do the rest after the paint
The visitor needs to see that their tap worked. Update the button, spinner or counter straight away, then push anything that is not needed for that frame, such as analytics calls, saving to storage or recalculating recommendations, until after the browser has painted.
button.addEventListener('click', () => {
button.classList.add('is-loading'); // visible change, cheap
requestAnimationFrame(() => {
setTimeout(() => { // runs after the next paint
updateBasket();
trackAddToBasket();
}, 0);
});
});
Avoid forced layout in loops
Reading a layout property after writing a style forces the browser to calculate layout immediately. Inside a loop that becomes dozens of layouts in one handler. Read everything first, then write everything.
// Slow: read, write, read, write ...
cards.forEach(c => { c.style.height = c.offsetWidth * 0.75 + 'px'; });
// Faster: all reads, then all writes
const widths = cards.map(c => c.offsetWidth);
cards.forEach((c, i) => { c.style.height = widths[i] * 0.75 + 'px'; });
Keep off-screen rendering cheap
For long pages with many sections, content-visibility: auto lets the browser skip rendering
work for sections far outside the viewport. Pair it with contain-intrinsic-size so the scrollbar
does not jump. For lists of hundreds or thousands of rows, render only the visible rows (virtualisation)
instead of all of them.
.product-section {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}
The fix, step by step
Step 1
Find the slow interactions in field data
Run the page through the Core Web Vitals checker and note the field INP on mobile and desktop,
then open the Search Console Core Web Vitals report to see which URL groups share the problem. Add the
web-vitals attribution build to the affected template and send the interaction target and
the three phase timings to your analytics. After a few days, list the five selectors that most often
produce a poor rating. Those are your targets.
Step 2
Reproduce the interaction with a throttled CPU
Open the page in Chrome as a first-time visitor, with the cookie banner still showing and a cold cache. In the DevTools Performance panel, set a 4x or 6x CPU slowdown, start recording, perform the interaction that your field data named, then stop. Find it in the Interactions track and confirm its duration is over 200 milliseconds. If it is fast even with throttling, test during page load, when start-up scripts are still running.
Step 3
Split the delay into its three phases
Hover over the interaction in the recording and read the input delay, processing duration and presentation delay, or compare them with the phase timings your analytics collected. Whichever phase is largest decides the fix. A large input delay means something else was hogging the main thread. A large processing duration means your handlers are too heavy. A large presentation delay means the style, layout and paint work after the handler is too expensive for the page.
Step 4
Cut input delay by clearing the main thread
Expand the long tasks just before the interaction and note which scripts own them.
Load non-essential third-party tags after consent or after the page has finished loading, remove tags
nobody uses, and move chat widgets to load on first click of a launcher button. For your own start-up
code, split large bundles, defer anything not needed for the first view, and break long initialisation
loops with scheduler.yield() so a tap can be handled between chunks.
Step 5
Shorten the event handler itself
Make
the handler do only what the next frame needs: update the button state or show a spinner, then defer
analytics, storage writes and background recalculation until after the paint. Split loops over large
data sets into chunks that yield, move pure computation such as sorting or parsing into a Web Worker,
and remove forced layout by reading all measurements before writing styles. In React, wrap non-urgent
state updates in startTransition so typing and clicking stay responsive.
Step 6
Reduce the rendering work after the handler
If presentation delay is the large phase, shrink what the browser has to restyle and
redraw. Change classes on the smallest element that needs them rather than on body, keep
the DOM smaller by rendering mega-menu panels and hidden tabs only when opened, virtualise long lists,
and add content-visibility: auto to long below-the-fold sections. Replace animated box-shadow,
filter or layout properties with transform and opacity, which
the compositor handles cheaply.
Step 7
Verify with real-user data
Repeat
the throttled recording and confirm the interaction now finishes well under 200 milliseconds, with no
single long task inside it. Deploy, then watch the phase timings from your web-vitals reporting;
they will show the change within days. The Chrome UX Report and Search Console use a rolling 28-day window,
so expect the field INP in the Core Web Vitals checker to improve gradually. Start validation in Search
Console once the fix is live on every page in the group.
Platform notes
WordPress
Poor INP on WordPress sites nearly always comes from plugins that add JavaScript to every page: sliders, pop-up builders, chat, social feeds and several analytics tools at once. Page builders also produce very deep DOMs, which feeds presentation delay. Check which scripts own the long tasks, dequeue plugin scripts on pages that do not use them, and prefer the "delay JavaScript until interaction" setting in a performance plugin only for tags that genuinely are not needed, since delayed scripts all run at once on the first tap and can make that first interaction worse. The WordPress Core Web Vitals guide covers this in more depth.
Shopify
Installed apps inject scripts into the storefront, and each can listen for clicks on product forms and cart buttons. Variant pickers and cart drawers that re-render a large part of the page on every change are common processing-time problems. Remove apps you no longer use, since some leave code behind in the theme, and test the add-to-basket and variant selection interactions specifically.
React, Next.js and other frameworks
Hydration makes the page unresponsive until the framework has attached its handlers, which shows up
as input delay for early taps. Keeping more components on the server, splitting client bundles and lazy-loading
interactive widgets that sit below the fold all help. Inside components, use startTransition
or useDeferredValue for expensive updates such as filtering, and memoise large lists so
a small state change does not re-render them. The Next.js
and Nuxt guide goes further into server and client components.
Hand-written and static sites
Here the usual culprits are third-party tags and older libraries such as jQuery plugins that bind many handlers and do heavy DOM work on every event. Event delegation, a single listener on a parent rather than one per item, reduces set-up work and memory, though it does not make an individual handler faster on its own.
Edge cases and misleading readings
- Good lab score, poor field INP. This is normal. Lighthouse does not interact with the page. Use Total Blocking Time as a rough warning sign only, and trust field attribution for the real answer.
- INP only poor on mobile. Phones have slower processors, so the same JavaScript takes several times longer. Throttle the CPU when testing, and see the mobile-only failures guide.
- Poor INP from interactions during load. If attribution shows a
loadStatebeforecomplete, visitors are tapping while start-up scripts run. Reducing and deferring those scripts matters more than optimising the handler. - Slow keyboard interactions in search boxes. A handler that filters or fetches on every key press can fail INP even if each call is modest. Update the input immediately and debounce the expensive part.
- Interactions inside iframes. Embedded content such as a third-party booking widget is measured separately in its own frame, and you cannot fix its code. You can load it only when needed, or replace it with a link to the provider's own page.
- Pages with no field data. Low-traffic pages have no CrUX figures of their own. Your own attribution data is then the only reliable measure.
Keeping INP good
- Keep the attribution reporting running. Leave the
web-vitalsreporting live so a new tag or feature that adds long tasks shows up in days rather than a month later. - Review every new third-party tag. Before adding a script through a tag manager, record a throttled interaction with and without it.
- Set a budget for JavaScript. Agree a maximum size for the scripts each template loads and fail the build or flag the pull request when it grows beyond that.
- Test interactions, not just loads. Add the key interactions, such as opening the menu, applying a filter and adding to basket, to your release checks with a throttled CPU.
- Watch long tasks. The Long Animation Frames API, available in Chrome, reports frames that took too long and which scripts contributed. Logging it alongside INP makes regressions easy to trace. See also fixing long tasks.
1. What is a good INP score?
Google rates Interaction to Next Paint as good when it is 200 milliseconds or less, needs improvement between 200 and 500 milliseconds, and poor above 500 milliseconds. The rating is based on the 75th percentile of real visits over 28 days, measured separately for mobile and desktop. That means at least three out of four visits must have their slowest interaction at or under 200 milliseconds for the page to pass. Lab tools cannot give you this figure directly.
2. Why does Lighthouse not show INP?
Lighthouse loads the page once on a simulated device and never clicks, taps or types, so there are no interactions for it to measure. It reports Total Blocking Time instead, which counts how long the main thread was blocked during loading. High TBT often goes with poor INP because the same scripts cause both, but a page can have low TBT and still have a slow handler on a filter or menu button. You need real-user data or a manual DevTools recording to measure INP.
3. Which interactions count towards INP?
Clicks with a mouse, taps on a touchscreen and key presses on a physical or on-screen keyboard all count. Hovering, scrolling and zooming do not. The browser watches every qualifying interaction for the whole time the visitor is on the page, not only during loading. For most pages the reported value is the slowest interaction of the visit, while on pages with very many interactions one outlier is ignored for every 50 so that a single freak delay does not dominate.
4. Is poor INP always caused by my own JavaScript?
No. Third-party scripts are a very common cause. Tag managers, analytics, advertising, consent platforms, session recording and chat widgets all run on the same main thread as your code. Their long tasks delay the start of your handlers, and some add their own click listeners that run on every tap. Record the interaction in DevTools and expand the long tasks to see which script owns them before assuming the problem is in your own code.
5. What does scheduler.yield() do and can I use it everywhere?
It pauses your function and lets the browser handle other work, such as painting or responding to input, before continuing where it left off. It is used to split one long task into several short ones. It is available in Chromium-based browsers such as Chrome and Edge, and support elsewhere varies, so wrap it in a small helper that falls back to a zero-length setTimeout when it is missing. The fallback is slightly less efficient but still breaks up the task.
6. Will a faster server improve INP?
Usually not by much. INP happens after the page has loaded, when a visitor interacts, and it depends on how busy the browser's main thread is and how much work your handlers and rendering do. Server speed affects Time to First Byte and Largest Contentful Paint. The exception is an interaction that waits for a network request before showing anything; showing immediate visual feedback, such as a spinner, keeps INP low even when the request itself is slow.
7. How long until Search Console shows my INP fix?
Search Console and the Chrome UX Report use a rolling 28-day window of real Chrome visits, so an improvement appears gradually as old visits drop out, and it can take close to four weeks to be fully reflected. Your own reporting with the web-vitals library will show the change within a few days. Once the fix is live across every page in the affected group, start validation in the Core Web Vitals report and let it run to completion.
The Core Web Vitals checker shows real-user field data and a Lighthouse lab test side by side for your URL, breaks down each metric and points to the assets and code behind every failure, so you can see whether INP is the metric holding the page back.
Open Core Web Vitals Fixes โ