Debugging INP Spikes in Production
A dashboard alert fires: p75 Interaction to Next Paint (INP) jumped from 180 ms to 340 ms overnight, crossing from Good into Needs Improvement. Synthetic Lighthouse runs look clean, so the regression lives in real traffic you cannot reproduce on your laptop. This page walks the exact triage path for that scenario: capture per-interaction attribution from the field, bucket it by route and element to find the offending interaction, reproduce the long task in DevTools, ship a targeted fix, and confirm the p75 delta closed. It assumes the field-data context established in the parent INP Tracking & Debugging guide.
INP is a session-level metric: it reports (roughly) the worst interaction a user had on the page, measured from input to the next paint. A spike almost never means every interaction got slower — it means one route or one component developed a long task that blocks the main thread. Attribution data is what turns “INP is up” into “the filter button on /search runs a 240 ms synchronous sort.”
Prerequisites
Before triaging, confirm the following are in place:
- A field-data RUM stream that records one row per page view with INP and contextual dimensions (route, device class, connection type). Capturing this correctly is covered in the Web Vitals API Implementation guide.
- The
web-vitalsattribution build (web-vitals/attribution), not the standard build. Only the attribution build exposesinteractionTarget,inputDelay,processingDuration, andpresentationDelay. - A beacon transport that flushes on
visibilitychange/pagehide, sending to your self-hosted beacon collection endpoint vianavigator.sendBeacon. - Chrome 121+ for local reproduction (stable INP attribution and the Performance panel’s Interactions track).
- A query engine (ClickHouse, BigQuery, or similar) so you can group by route and element.
The INP threshold bands you are triaging against:
| Band | p75 INP | Engineering action |
|---|---|---|
| Good | ≤ 200 ms | Monitor; no action required |
| Needs Improvement | > 200 ms and ≤ 500 ms | Triage the top route/element bucket this week |
| Poor | > 500 ms | Page on-call; the worst interaction blocks for half a second |
How to debug an INP spike in production
Step 1 — Capture per-interaction attribution from the field
The standard onINP callback gives you a number; the attribution build gives you the breakdown that tells you where the time went. Use web-vitals/attribution and forward the sub-parts plus the interaction target.
import { onINP } from 'web-vitals/attribution';
function sendBeacon(metric) {
const a = metric.attribution;
const body = JSON.stringify({
metric: 'INP',
value: metric.value, // total interaction latency, ms
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
route: location.pathname, // bucket dimension
target: a.interactionTarget, // CSS selector of the element hit
eventType: a.interactionType, // 'pointer' | 'keyboard'
inputDelay: Math.round(a.inputDelay),
processingDuration: Math.round(a.processingDuration),
presentationDelay: Math.round(a.presentationDelay),
longestScript: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL ?? null,
});
navigator.sendBeacon('/rum/inp', body);
}
// reportAllChanges:false (default) reports the final INP per page at unload.
onINP(sendBeacon, { reportAllChanges: false });
Why: interactionTarget collapses an interaction to a stable CSS selector you can GROUP BY. The three timing sub-parts (inputDelay, processingDuration, presentationDelay) tell you which phase is slow before you ever open DevTools — a fix for input delay (a busy main thread at the moment of the tap) is different from a fix for processing duration (your handler itself is slow). The deep-dive on this build lives in Debugging Web Vitals with the Attribution Build.
Step 2 — Bucket by route and element
A p75 number is useless for triage; you need the distribution split by where the interaction happened. Group your raw beacons by route and target, then read the p75 of each bucket and how much each contributes.
SELECT
route,
target,
count() AS samples,
quantile(0.75)(value) AS p75_inp,
round(avg(inputDelay)) AS avg_input_delay,
round(avg(processingDuration)) AS avg_processing,
round(avg(presentationDelay)) AS avg_presentation
FROM rum_inp
WHERE ts >= now() - INTERVAL 24 HOUR
GROUP BY route, target
HAVING samples > 100 -- ignore thin, noisy buckets
ORDER BY p75_inp DESC
LIMIT 20;
Why: the offending bucket usually jumps out — one route/target pair has a p75 well above 200 ms while everything else sits in the Good band. The phase columns point at the fix class: high avg_processing means the handler is the long task; high avg_input_delay means an unrelated task (often third-party script or hydration) was running when the user tapped.
Step 3 — Reproduce the interaction in the DevTools Performance panel
Open the offending route, start a Performance recording, and perform the exact interaction the selector named (for example, clicking the element matching button.facet-apply). Throttle CPU to 4× and network to Slow 4G so your fast machine resembles the field.
1. DevTools → Performance → gear icon → CPU: 4× slowdown, Network: Slow 4G
2. Click Record (or Cmd/Ctrl+E)
3. Perform the one interaction from Step 2's worst bucket
4. Stop recording
5. Open the "Interactions" track; the bar with a red corner is over budget
Why: the Interactions track shows the input delay, processing, and presentation phases as a single bar aligned to the main-thread flame chart directly below it. Reproducing under throttling reproduces the field condition; without it, a 240 ms field interaction often finishes in 40 ms locally and you conclude “works on my machine.”
Step 4 — Find the long task under the interaction
Click the over-budget interaction bar, then look at the main-thread track directly beneath it during the processing phase. A task drawn with a red triangle in its top-right corner is a long task (> 50 ms). Expand it to the slowest call frame.
// Confirm the long task programmatically while you repro:
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
console.warn(`long task ${Math.round(e.duration)}ms`, e.attribution?.[0]?.name);
}
}).observe({ type: 'longtask', buffered: true });
Why: INP is dominated by whatever long task overlaps the interaction. The flame chart names the function; the longtask observer confirms the duration and the script context, so you stop guessing and know the exact frame to fix — typically a synchronous loop, a layout-thrashing read/write, or a JSON parse on the hot path.
Step 5 — Apply the matching fix
Choose the fix by which phase dominated in Step 2:
- High
processingDurationfrom a long synchronous loop → break it up so the browser can paint between chunks. The full treatment is in Breaking Up Long Tasks with scheduler.yield.
async function applyFacets(items) {
const out = [];
for (let i = 0; i < items.length; i++) {
out.push(transform(items[i]));
if (i % 100 === 0) {
// Yield so a queued paint/input can run between chunks.
if (typeof scheduler !== 'undefined' && scheduler.yield) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
}
}
return out;
}
- High
inputDelayfrom a heavy module loading on the route → code-split it so it is not parsed on the interaction’s critical path.
button.addEventListener('click', async () => {
// Defer the heavy module to interaction time, off the initial bundle.
const { runReport } = await import('./report-builder.js');
runReport();
});
- A handler firing on every keystroke or scroll tick → debounce so the expensive work runs once the user pauses.
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
input.addEventListener('input', debounce((e) => filterList(e.target.value), 150));
Why: each fix removes a different source of main-thread contention. Yielding gives the painter a chance to render between work chunks (cutting processing duration); code-splitting keeps the bundle off the path so the tap is not delayed by parse/compile (cutting input delay); debouncing collapses N expensive runs into one (cutting total work).
Step 6 — Confirm the p75 delta in field data
Ship behind a flag or to a canary, then re-run the Step 2 query scoped to the new build and compare p75 for the same route/target bucket.
SELECT
build,
quantile(0.75)(value) AS p75_inp,
count() AS samples
FROM rum_inp
WHERE route = '/search'
AND target = 'button.facet-apply'
AND ts >= now() - INTERVAL 6 HOUR
GROUP BY build
ORDER BY build;
Why: local DevTools confirms the long task is gone on one machine; only field p75 confirms it is gone for real users across the device and network mix. Wait for enough samples (typically a few thousand per bucket) before declaring the regression closed — INP needs interactions, which accrue slower than page views.
Verifying it works
- DevTools Interactions track: after the fix, the once-red interaction bar fits under budget and no long-task triangle overlaps it during the processing phase.
- Console: the
longtaskobserver from Step 4 no longer logs a task over the interaction; any remaining tasks are sub-50 ms. - RUM dashboard: the Step 6 query shows the offending bucket’s p75 back under 200 ms, and overall page p75 returns to its pre-spike baseline. Confirm
processingDuration(orinputDelay) specifically dropped — that proves your fix, not noise, moved the number.
Edge cases & gotchas
interactionTargetis(not set)or empty. The element was removed from the DOM before the beacon serialized (common with modals and toasts). Capture a stabledata-*attribute or the nearest persistent ancestor’s selector at handler time rather than relying solely on the live target.- The spike is input delay, not your handler. If
processingDurationis small butinputDelayis large, your code is innocent — a third-party tag or a hydration burst was occupying the main thread when the user tapped. Look at Long Animation Frames (LoAF) and the script source, not your event handler. - Safari and Firefox report no INP. The Event Timing API that backs INP is Chromium-only today. Your field p75 reflects Chrome traffic; do not assume non-Chromium users are unaffected, and segment by browser so a Chrome-only fix is measured against Chrome-only data.
- Background-tab interactions inflate presentation delay. A tab throttled in the background can record a large
presentationDelaybecause paints are deferred. Filter or flag interactions wheredocument.visibilityStatewashiddenso they do not masquerade as a regression. reportAllChanges:truechanges the number. It reports every interaction, not the page-level INP. Useful for granular debugging, but never aggregate it as INP — you will inflate sample counts and skew p75. Keep the production beacon on the defaultfalse.- Keyboard interactions on mobile. Virtual-keyboard
inputevents can fire in bursts; an un-debounced handler that looks fine on desktop can dominate INP on mobile. Always reproduce in Step 3 with both pointer and keyboard input where the route accepts text.
FAQ
Why does my synthetic test pass while field INP is Poor?
Synthetic tools run one scripted interaction on a fast machine. INP is the worst real interaction across a session on the full device and network mix. A 240 ms field interaction routinely finishes in 40 ms on an unthrottled laptop, which is why you must reproduce with 4× CPU throttling in Step 3 and confirm against field p75 in Step 6.
Which attribution field tells me what to fix?
Compare the three sub-parts. Large processingDuration means your handler is the long task — break it up or move work off the path. Large inputDelay means something else blocked the main thread at tap time — investigate third-party scripts or hydration. Large presentationDelay points at heavy rendering or layout work after the handler returns.
How long should I wait before confirming the fix?
Until the offending route/element bucket accrues enough interactions for a stable p75 — typically a few thousand samples. INP accrues from interactions, not page views, so low-traffic routes can take a day or more before the delta is trustworthy.
Related
- INP Tracking & Debugging — the parent guide on field measurement and the INP algorithm.
- Breaking Up Long Tasks with scheduler.yield — the processing-duration fix in depth.
- Self-Hosted Beacon Collection — the ingestion endpoint that receives your INP attribution beacons.