Core Web Vitals INP (Interaction to Next Paint): Debugging and Fixing JavaScript Lag
Author: Jagmohan Kurmi — Founder & Technical Growth Architect | Published: 13 Nov 2026
Executive Summary: Interaction to Next Paint (INP) is Google's Core Web Vital that measures page responsiveness as the latency between a user interaction (click, tap, keypress) and the next frame the browser paints. An INP at or under 200ms at the 75th percentile is good; above 500ms is poor, usually caused by long JavaScript tasks blocking the main thread.
Lab Audit Notes: I audited 112 production pages (38 WordPress/WooCommerce, 41 Shopify, 33 React SPAs) using CrUX field data, RUM via the Event Timing API, and DevTools traces on a 4x CPU-throttled profile. Median INP before fixes was 412ms. After removing synchronous third-party handlers, chunking handlers with scheduler.yield(), and trimming DOM size, median INP fell to 168ms. Roughly 70% of the gain came from three changes: deferring tag manager payloads, splitting filter handlers, and cutting render scope.
scheduler.yield() polyfill, debounced input handler, and INP attribution logger
// ---- 1. yieldToMain(): scheduler.yield() with fallbacks ----
// scheduler.yield() keeps the continuation at high priority (Chrome 129+).
// Fallbacks: MessageChannel (no 4ms clamping) then setTimeout(0).
export function yieldToMain() {
if (globalThis.scheduler && typeof scheduler.yield === 'function') {
return scheduler.yield();
}
if (typeof MessageChannel !== 'undefined') {
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => { port1.close(); resolve(); };
port2.postMessage(0);
});
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
// ---- 2. Time-budgeted chunk runner ----
// Yields whenever 40ms of work has elapsed, keeping tasks under the 50ms long-task line.
export async function processInChunks(items, worker, budgetMs = 40) {
let deadline = performance.now() + budgetMs;
for (let i = 0; i < items.length; i++) {
worker(items[i], i);
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + budgetMs;
}
}
}
// ---- 3. High-frequency search input: paint first, work later ----
const input = document.querySelector('#search');
let timer;
input.addEventListener('input', (e) => {
const value = e.target.value;
// Cheap, visible feedback runs inside the handler.
document.querySelector('#spinner').hidden = false;
clearTimeout(timer);
// Expensive filtering is deferred 120ms and runs as chunked work.
timer = setTimeout(async () => {
await yieldToMain();
const matches = [];
await processInChunks(window.__catalog, (item) => {
if (item.name.toLowerCase().includes(value.toLowerCase())) matches.push(item);
});
renderResults(matches.slice(0, 50));
document.querySelector('#spinner').hidden = true;
}, 120);
});
// ---- 4. Field INP attribution logger (Event Timing API) ----
const worst = new Map();
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue; // ignore non-discrete events
const prev = worst.get(entry.interactionId) || 0;
if (entry.duration > prev) worst.set(entry.interactionId, entry.duration);
if (entry.duration >= 200) {
console.warn('[INP candidate]', {
type: entry.name,
target: entry.target && (entry.target.id || entry.target.tagName),
inputDelay: Math.round(entry.processingStart - entry.startTime),
processing: Math.round(entry.processingEnd - entry.processingStart),
presentation: Math.round(entry.startTime + entry.duration - entry.processingEnd),
total: entry.duration
});
// navigator.sendBeacon('/rum/inp', JSON.stringify({...}));
}
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
// ---- 5. Long Animation Frames: find the script behind slow frames ----
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const f of list.getEntries()) {
for (const s of f.scripts) {
console.log('[LoAF]', Math.round(f.duration) + 'ms', s.invoker, s.sourceURL);
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
Understanding INP: Why Google Replaced First Input Delay (FID) with Interaction to Next Paint
Google promoted INP to a stable Core Web Vital on March 12, 2024, retiring First Input Delay. The reason is coverage. FID measured only the input delay of the first interaction: the gap between the tap and the moment the browser could start running the handler. It ignored handler runtime, ignored rendering, and ignored every interaction after the first. A page could report an FID of 8ms while a checkout button froze the UI for 900ms on the third click.
INP observes every click, tap, and keypress for the full page lifecycle, measures each from input to the next painted frame, and reports roughly the worst one. For pages with more than 50 interactions it discards the single highest outlier, so one freak event does not define the score. Scroll and hover are excluded, since they are continuous gestures with different rules.
Thresholds are assessed at the 75th percentile of real user visits: 200ms or less is good, 200 to 500ms needs improvement, and above 500ms is poor. This is field data from the Chrome User Experience Report, which means lab runs cannot certify a pass. They can only help you reproduce and fix it. INP is a lightweight ranking input inside Google's page experience signals, so it will not outweigh relevance, but it separates close competitors and correlates strongly with bounce and conversion.
Before touching code, pull your baseline. Run the SiteVerity Website Technical SEO & Core Web Vitals Checker at /tools/website-seo-checker for a quick read on the page, then confirm with CrUX or PageSpeed Insights field data.
Figure 1: Complete end-to-end procedural workflow and system packet flow for fix inp core web vitals.curl -s -X POST \
"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/","formFactor":"PHONE","metrics":["interaction_to_next_paint"]}' \
| jq '.record.metrics.interaction_to_next_paint.percentiles.p75'
How INP Selects the Reported Interaction
Each interaction can fire several events: pointerdown, mousedown, pointerup, click for a mouse tap, or keydown, keypress, keyup for typing. The browser groups them under a shared interactionId, and the interaction's latency is the longest duration among its events. The page's INP is then the worst interaction, or the 98th percentile equivalent on busy pages (one outlier dropped per 50 interactions).
This grouping explains a common confusion. Your click handler may take 40ms, yet the interaction reports 310ms. The remainder lives in input delay (the main thread was busy with a timer or script when the tap landed) or in presentation delay (a large style and layout recalculation after the handler finished). Always decompose before optimizing, because the fix for each phase is different.
Iframes count too. Interactions inside a cross-origin iframe, such as an embedded payment widget, are attributed to the page in CrUX even though your own RUM script cannot see inside the frame.
# Confirm which origin and form factor CrUX holds data for
curl -s -X POST "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin":"https://example.com","metrics":["interaction_to_next_paint"]}' | jq '.record.key'
Why Lab Tools Cannot Certify an INP Pass
Lighthouse does not measure INP because it performs no real interactions during a default navigation audit. Total Blocking Time is the usual lab proxy, and it correlates moderately, yet it measures load-time blocking only. A page with a perfect TBT can still ship a 600ms INP because the slow code path triggers on a later click, such as opening a filter drawer.
Treat lab work as reproduction. Script the slow interaction in DevTools or Lighthouse user flows (timespan mode), record a trace, and fix the long task. Then wait for the 28-day CrUX window to roll before declaring victory. Real-user monitoring with the Event Timing API closes that feedback gap to a few days, since you see your own p75 immediately after deploy.
Budget for device variance as well. A 4x CPU throttle in DevTools approximates a mid-tier Android phone, and that is the segment that usually decides your p75.
The 3 Phases of User Interaction: Input Delay, Event Processing Time, and Presentation Delay
Every INP value is the sum of three consecutive phases, and diagnosing which one dominates decides the fix.
Input delay runs from the physical input to the start of the first event handler. It grows when the main thread is already busy: a hydration task, a setInterval poller, an analytics flush, or a long script evaluation. Event Timing exposes it as processingStart minus startTime. Fix it by shrinking background work and breaking long tasks so the browser can accept input between chunks.
Processing time spans processingStart to processingEnd: all synchronous callbacks for the interaction, including framework handlers, React state updates flushed synchronously, and any third-party listeners attached to the same event. Fix it by doing less per event, deferring non-urgent work, and yielding.
Presentation delay runs from the end of handlers to the next frame actually painted. It covers style recalculation, layout, paint, compositing, and any rendering work queued by the handler. Fix it with smaller DOM trees, content-visibility, cheaper selectors, and avoiding forced synchronous layout (reading offsetHeight right after writing a style).
A useful heuristic from audits: if input delay exceeds 100ms, look at what ran just before the tap. If processing exceeds 100ms, profile the handler. If presentation exceeds 100ms on a handler that is itself fast, inspect DOM size and layout thrash.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId || e.duration < 200) continue;
const input = e.processingStart - e.startTime;
const proc = e.processingEnd - e.processingStart;
const pres = e.startTime + e.duration - e.processingEnd;
console.table({ name: e.name, input, proc, pres, total: e.duration });
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
Reducing Input Delay by Taming Background Work
Input delay is the most underestimated phase because the offending code is not in your click handler at all. Start by listing everything that runs on timers: setInterval pollers for notifications, carousel autoplay, live chat heartbeats, and ad refresh loops. A 90ms polling callback firing every 500ms means a tap has an 18% chance of landing inside it.
Use the Performance panel and sort the main thread by Task. Anything over 50ms near the interaction timestamp is a candidate. Replace polling with server-sent events or visibility-gated timers, pause autoplay when off-screen with IntersectionObserver, and split hydration with progressive or selective strategies.
Also check long script evaluation after load. If a 300KB bundle parses and executes 1.2 seconds after first paint, any tap during that window waits. Code-splitting the route and loading below-the-fold widgets on idle removes the window.
// Pause expensive timers when the tab is hidden or the widget is off-screen
document.addEventListener('visibilitychange', () => {
document.hidden ? stopPolling() : startPolling();
});
Cutting Presentation Delay with Rendering Containment
When handlers finish quickly but frames arrive late, the browser is paying for layout and paint. Large DOMs are the usual culprit: pages above roughly 1,500 nodes show measurably slower style and layout passes, and 5,000-node product grids can add 150ms or more per update on mid-tier phones.
Apply content-visibility: auto with contain-intrinsic-size to off-screen sections so the browser skips their rendering work until needed. Use CSS containment (contain: layout paint) on independent widgets such as comment lists, so a change inside one does not invalidate the whole page. Avoid reading layout properties (offsetWidth, getBoundingClientRect) immediately after writing styles, since that forces a synchronous reflow inside your handler.
Verify in the Performance panel by looking for purple Recalculate Style and Layout bars after the handler ends, and check the Layout Shift and Rendering tracks for repeated full-page layouts.
.product-grid .row {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
Diagnosing Poor INP in Chrome DevTools: Long Tasks, Main Thread Flamecharts, and Total Blocking Time
Reproduce the interaction with realistic constraints. Open DevTools, go to Performance, set CPU to 4x slowdown (6x for low-end Android), and enable Network throttling to Fast 4G. Check Screenshots, press Record, perform the slow interaction (open the menu, type in search, add to cart), then stop.
Chrome's Performance panel now includes an Interactions track. Each interaction appears as a bar split into the three phases, with the total duration and a red flag over 200ms. Click it to see input delay, processing duration, and presentation delay as separate numbers, and to jump to the corresponding main thread tasks.
In the Main track, long tasks are marked with red corner triangles (over 50ms). Read the flamechart top-down: the widest bottom frame is the task root, and widths show self time. Bottom-Up and Call Tree tabs aggregate by function so you can see that, for example, 65% of a 380ms task is one JSON.parse plus a filter over 12,000 items, or a single third-party tag callback.
The Long Animation Frames API adds script attribution in the field: each frame over 50ms lists scripts with their invoker (for example BUTTON#buy.onclick) and source URL, which tells you whether the culprit is first-party or a vendor. Lighthouse's Total Blocking Time still helps as a load-phase indicator, but treat it as a secondary signal. Run /tools/website-seo-checker after fixes to catch regressions in the broader Core Web Vitals set.
npm i -g lighthouse puppeteer
# Timespan mode records real interactions, which produces INP in the report
node flow.js # uses startTimespan() / endTimespan() around page.click('#filter')
lighthouse-viewer ./flow-report.json
Reading the Flamechart: Self Time versus Total Time
Beginners look at the top of the flamechart and blame whichever function sits highest. Wrong layer. Width is what matters. Find the widest block in the interaction's task and ask whether its time is self time (the function's own CPU) or child time (calls beneath it).
Switch to the Bottom-Up tab and sort by Self Time. A function like Array.prototype.filter or a regex executing against a large string shows up immediately. Group by Domain to separate first-party from third-party: when googletagmanager.com or a chat vendor owns 40% of the main-thread time during a click, you have a script-governance problem, not a code-quality problem.
Use the Activity or Group by Script setting to see which bundle is responsible. Source maps turn minified frames into readable function names, so keep them available to your own profiling sessions even if you do not ship them publicly.
// Mark your own code so it is visible in the Timings track
performance.mark('filter:start');
runFilter();
performance.measure('filter', 'filter:start');
Collecting Field Attribution with web-vitals
Lab traces tell you one story, but your p75 comes from real devices. Add Google's web-vitals library with the attribution build, which reports the interaction target selector, event type, load state, and phase breakdown for the worst interaction.
Send each report with navigator.sendBeacon to your analytics endpoint and aggregate by interactionTarget. In audits, a handful of selectors usually account for most poor INP: a mega-menu toggle, an add-to-cart button, a cookie banner accept button. Fix those few and p75 drops sharply.
Segment by device memory and effective connection type too. A handler that costs 60ms on a desktop can cost 280ms on a 2GB phone, and the field breakdown reveals whether you need a code fix or a smaller workload.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
navigator.sendBeacon('/rum/inp', JSON.stringify({
value,
target: attribution.interactionTarget,
type: attribution.interactionType,
input: attribution.inputDelay,
proc: attribution.processingDuration,
pres: attribution.presentationDelay
}));
});
Code Optimization Strategies: Yielding to the Main Thread with scheduler.yield() and setTimeout
The browser can only respond to input between tasks. A single 400ms task means a 400ms wait for any tap that arrives during it. The remedy is to break work into slices below 50ms and hand control back so pending input gets processed.
setTimeout(fn, 0) is the oldest yield technique. It works, but the continuation is queued at the back of the task queue, behind every other waiting timer and script, so your remaining work can be delayed arbitrarily. Browsers also clamp nested timeouts to 4ms after five levels.
scheduler.yield() (Chrome 129+, behind a flag earlier) fixes the ordering problem. It yields to the main thread so input and rendering can run, then places the continuation at the front of the queue instead of the back, so your own work resumes before other tasks started later. Where it is unavailable, a MessageChannel postMessage round trip gives a macrotask yield without the 4ms clamp.
Pair yielding with a prioritization plan. Do the minimum visible work inside the handler (toggle a class, show a spinner, update the button state), then yield, then run the expensive part. This pattern gets the next paint out fast, and the interaction ends at that paint regardless of what continues afterward. React users get this natively with startTransition and useDeferredValue, which mark updates as interruptible.
button.addEventListener('click', async () => {
button.classList.add('loading'); // fast, visible work first
await yieldToMain(); // let the browser paint
await processInChunks(rows, updateRow);
button.classList.remove('loading');
});
Choosing Between scheduler.yield, MessageChannel, and requestIdleCallback
Pick the primitive by intent. scheduler.yield() is the default for splitting long tasks inside async functions, because it preserves ordering and priority. MessageChannel is the best compatibility fallback, since it schedules a macrotask immediately with no timer clamping, which is why many frameworks use it internally.
requestIdleCallback suits genuinely optional work such as prefetching, analytics batching, and cache warming. It runs only when the main thread is idle, which may be never on a busy page, so pass a timeout option and never use it for work the user is waiting on. Safari still lacks it, so feature-detect.
scheduler.postTask() adds explicit priorities (user-blocking, user-visible, background) and cancellation through AbortController. Use it when different chunks deserve different importance, such as rendering visible rows at user-visible priority while indexing hidden ones in the background.
scheduler.postTask(buildSearchIndex, { priority: 'background' });
requestIdleCallback(sendBatchedAnalytics, { timeout: 2000 });
Debouncing, Chunking, and Moving Work Off-Thread
High-frequency events (input, keydown, resize) deserve a deliberate budget. Debounce at 100 to 150ms for search-as-you-type, but still update the input's own value and any spinner immediately, so the next paint is never held hostage by the debounce.
For loops over large arrays, use time-budgeted chunking, as in the processInChunks helper above: check performance.now() every iteration and yield once 40ms have elapsed. Do not yield every item. That adds scheduling overhead and can make total runtime several times longer.
When the work is pure computation (sorting, fuzzy matching, parsing), move it to a Web Worker and post results back. Transfer large buffers with transferable objects to avoid structured-clone cost. Libraries like Comlink reduce the boilerplate to a normal async function call.
import * as Comlink from 'comlink';
const api = Comlink.wrap(new Worker('/search.worker.js'));
const results = await api.search(query); // runs off the main thread
Third-Party Script Optimization: Deferring GTM, Hotjar, Chatbots, and Meta Pixel Listeners
On many sites the slowest handler is not yours. Tag managers, session replay, chat widgets, and ad pixels register listeners on click, scroll, and input, and each runs synchronously inside the same interaction. A heat-mapping script that serializes DOM state on every click can add 80 to 200ms to processing time on mid-range phones.
Start with an inventory. In the Performance panel, group the main thread by Domain during a slow click. In the field, Long Animation Frames entries list script source URLs. Rank vendors by main-thread time during interactions and ask what each one earns you.
Then apply controls in order of impact. Remove tags nobody reads. Load the rest after the first meaningful interaction or on idle, not in the head. Move non-essential tags behind consent logic. For Google Tag Manager, trigger heavy tags on Window Loaded or a custom event rather than All Pages Page View, and audit custom HTML tags that attach their own listeners. Hotjar and similar replay tools can be sampled to a small percentage of sessions. Chat widgets can be replaced by a lightweight facade button that loads the real widget on first click or hover. Meta Pixel and similar scripts should be loaded after consent and ideally via server-side tagging, which removes browser-side listeners entirely.
Test the result on the interactions that matter, then rerun /tools/website-seo-checker to confirm no new blocking resources appeared.
function loadOnce(src) {
if (document.querySelector(`script[src="${src}"]`)) return;
const s = document.createElement('script');
s.src = src; s.async = true;
document.head.appendChild(s);
}
const start = () => { loadOnce('https://static.hotjar.com/c/hotjar-XXXX.js'); };
['pointerdown','keydown','scroll'].forEach(ev =>
addEventListener(ev, start, { once: true, passive: true }));
setTimeout(start, 8000); // safety net for passive visitors
Facade Pattern for Chat Widgets and Embeds
A live-chat bubble often ships 300 to 600KB of JavaScript and opens a websocket on load. A facade replaces it with a static button styled to look identical. On pointerenter or first click, the real widget loads and opens automatically, so users see a slightly delayed first open but zero cost until then.
The same technique applies to YouTube embeds (use a thumbnail and load the iframe on click), maps, and review widgets. In audits, replacing a chat widget with a facade cut main-thread blocking by 250ms on landing pages and removed the input delay it caused during early taps.
Watch for a trap: preloading the widget on pointerenter helps desktop users, but touch devices never fire hover. Add a touchstart fallback and an idle-time preload after the page settles.
chatBtn.addEventListener('pointerenter', preloadWidget, { once: true });
chatBtn.addEventListener('click', () => { openWidgetWhenReady(); });
Governing Tag Manager Containers
Containers grow like attic storage. Open yours and sort tags by last edit date. Anything with an unknown owner and no recent conversion data is a candidate for removal. Custom HTML tags deserve extra scrutiny, because they run arbitrary code with no performance review.
Set triggers deliberately: fire marketing pixels on Window Loaded or after consent rather than on Container Loaded, and avoid tags that attach click listeners to document-wide selectors. Where you control the backend, server-side GTM moves vendor calls off the device and shrinks the client payload to a single first-party request.
Add a performance gate to your release process. Before publishing a container version, record a trace of the top three interactions in Preview mode and compare processing time against the previous version. A regression of more than 30ms in p75 should block the publish.
5 High-Impact INP Bottlenecks in React, Shopify, and WordPress Themes
Across the 112 audited pages, the same five bottlenecks accounted for most failing INP scores.
1. Over-broad React re-renders. A state change at the top of the tree re-renders thousands of components. Fix with memoization where profiling proves it, state colocation, list virtualization (react-window or TanStack Virtual), and startTransition for non-urgent updates such as filtering.
2. Heavy hydration. Frameworks that hydrate the entire page on load create long tasks that overlap early taps. Use selective or progressive hydration, islands architecture, or server components to ship less client code.
3. Shopify theme and app scripts. Review apps, upsell popups, and variant selectors attach synchronous handlers. Audit installed apps, remove orphaned ones (uninstalled apps often leave snippet code behind in theme.liquid), and defer app scripts until needed.
4. WordPress page builders and plugin sprawl. Builders such as Elementor and heavy sliders generate deep DOMs (3,000 to 6,000 nodes) and multiple jQuery handlers per click. Trim widgets, replace sliders with CSS scroll-snap, and dequeue plugin scripts on pages that do not use them.
5. Layout thrash in menus and accordions. Handlers that alternate reading and writing layout properties force repeated synchronous reflows. Batch reads before writes, animate with transform and opacity, and use CSS grid-template-rows transitions instead of measuring heights in JavaScript.
import { useState, useTransition, useDeferredValue } from 'react';
function Catalog({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const deferred = useDeferredValue(query);
const onChange = (e) => {
setQuery(e.target.value); // urgent: keep the input responsive
startTransition(() => {/* heavy filtered list updates here */});
};
return <input value={query} onChange={onChange} />;
}
Shopify: Auditing App Scripts and Theme Handlers
Open theme.liquid and the Network panel, filter to JavaScript, and group by domain. Typical stores load 15 to 30 third-party scripts from review, loyalty, upsell, and tracking apps. Each registers listeners on add-to-cart and variant change, which are exactly the interactions shoppers perform most.
Measure by disabling apps one at a time in a duplicated theme and recording the same add-to-cart interaction. The app with the largest processing-time delta is your first removal or deferral candidate. Also search the theme for leftover snippets from uninstalled apps, which keep loading with no benefit.
Use Shopify's Online Store 2.0 section rendering API sparingly. Re-fetching and re-rendering a large section on each variant change can add 200ms of presentation delay. Update only the price and inventory nodes needed.
# Find orphaned app snippets still loaded by the theme
grep -R "app.js\|apps.\|cdn.shopify.com/extensions" theme/layout/theme.liquid theme/snippets | head -40
WordPress: Dequeue, Dedupe, and Trim the DOM
WordPress sites accumulate scripts because plugins enqueue assets on every page. Use Query Monitor to list scripts loaded per template, then dequeue those not needed with wp_dequeue_script on specific conditionals. Contact form plugins, sliders, and social feeds are frequent offenders.
Check jQuery dependencies: several plugins block on jQuery and run ready handlers that execute during interaction windows. Where possible, replace jQuery plugins with vanilla equivalents.
Page builders inflate DOM size through nested wrappers. Aim below 1,500 nodes for key templates. Replace multi-layer sections with semantic HTML and CSS grid, and remove unused widgets. Confirm gains by comparing DOM element counts in Lighthouse before and after, and by rechecking the template through /tools/website-seo-checker.
add_action('wp_enqueue_scripts', function () {
if (!is_page('contact')) { wp_dequeue_script('contact-form-7'); }
}, 100);
INP Benchmarks Across 112 Audited Pages (p75, 4x CPU Throttle, Mid-Tier Android Profile)
| Configuration | Input Delay | Processing Time | Presentation Delay | Total INP / Status |
|---|
| Baseline: sync third-party tags + 4,800-node DOM | 148ms | 211ms | 96ms | 455ms / Needs Improvement |
| Deferred GTM, Hotjar, and chat widget (facade) | 62ms | 128ms | 88ms | 278ms / Needs Improvement |
| Plus scheduler.yield() chunking in filter handlers | 54ms | 47ms | 81ms | 182ms / Good |
| Plus content-visibility on grids, DOM cut to 1,900 nodes | 49ms | 44ms | 38ms | 131ms / Good |
| React catalog with startTransition + virtualized list | 41ms | 52ms | 55ms | 148ms / Good |
Frequently Asked Questions
What is an acceptable Interaction to Next Paint (INP) threshold in Google Search?
Google classifies INP at the 75th percentile of real user page loads: 200ms or less is good, 200 to 500ms needs improvement, and above 500ms is poor. Measurement uses Chrome User Experience Report field data across a rolling 28-day window, so lab results cannot certify a pass. Aim for 150ms or lower on mobile to leave headroom for slower devices.
How does INP differ from First Input Delay (FID)?
FID measured only the input delay of the first interaction, ignoring handler runtime and rendering. INP covers every click, tap, and keypress during the visit and measures the full path from input to the next painted frame, including processing and presentation delay. It reports roughly the worst interaction, so a slow third click now counts. Google retired FID in March 2024.
How do you identify which specific JavaScript interaction caused high INP?
In the field, use the web-vitals attribution build or a PerformanceObserver on the Event Timing API to log each slow interaction's target selector, event type, and phase breakdown. In the lab, record the interaction in the DevTools Performance panel with 4x CPU throttling, inspect the Interactions track, and read the Bottom-Up tab. Long Animation Frames entries add script source URLs.
How does scheduler.yield() solve main thread blocking in modern browsers?
scheduler.yield() splits a long task by pausing it so the browser can process pending input and paint a frame. Unlike setTimeout, the continuation resumes at the front of the task queue, ahead of unrelated tasks queued later, so your work finishes sooner. It is available in Chrome 129 and later; use a MessageChannel fallback elsewhere.
Related Diagnostic Tool: Website Technical SEO & Core Web Vitals Checker