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.”

INP regression triage flow Field attribution is bucketed by route and element, the worst interaction is reproduced in DevTools, the long task is fixed, and the p75 delta is confirmed. 1. Capture attribution build 2. Bucket route + element 3. Reproduce DevTools trace 4. Find task long task > 50 ms 5. Fix yield / split / debounce 6. Confirm p75 delta in RUM INP = inputDelay + processingDuration + presentationDelay Good ≤ 200 ms · Needs Improvement ≤ 500 ms · Poor > 500 ms
The triage loop, from field attribution to a confirmed p75 delta. Thresholds follow the current INP specification.

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-vitals attribution build (web-vitals/attribution), not the standard build. Only the attribution build exposes interactionTarget, inputDelay, processingDuration, and presentationDelay.
  • A beacon transport that flushes on visibilitychange/pagehide, sending to your self-hosted beacon collection endpoint via navigator.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:

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 inputDelay from 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 longtask observer 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 (or inputDelay) specifically dropped — that proves your fix, not noise, moved the number.

Edge cases & gotchas

  • interactionTarget is (not set) or empty. The element was removed from the DOM before the beacon serialized (common with modals and toasts). Capture a stable data-* 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 processingDuration is small but inputDelay is 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 presentationDelay because paints are deferred. Filter or flag interactions where document.visibilityState was hidden so they do not masquerade as a regression.
  • reportAllChanges:true changes 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 default false.
  • Keyboard interactions on mobile. Virtual-keyboard input events 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.