Skip to content

Debugging INP: Find the Interaction That Is Costing You

Split each slow interaction into input delay, processing and presentation delay, then name the script behind it with Long Animation Frames.

· · 14 min read
Five clicks split into three INP phases: the menu waited 268 ms for input, the long list needed 215 ms to paint

Quick Take

INP debugging is a three-way split: take the slow interaction, separate input delay, processing and presentation delay, then read the Long Animation Frame scripts that overlap it. In a Chromium 151 harness with five clicks, the slowest one was a button whose handler did nothing: it waited 268 ms behind a timer job. The other slow clicks each needed a different fix.

An INP number tells you how bad an interaction was. It doesn't tell you which one, and it doesn't tell you where the time went. That second part matters more than people expect, because the same 200 ms can be a slow click handler, a script that was already running when the user clicked, or a page that takes 200 ms to paint after a one-line change. Each of those has a different fix and a different file to open.

I built a small harness that makes all three happen on purpose, then measured what a 70-line reporter and the web-vitals attribution build say about each. The code is in the inp-three-phases-loaf example, and it asserts every result below. The fix side of the story, how to yield to the main thread, is in the measured yielding guide. This article is about the step before it: knowing what to fix.

What does one INP number hide?

web.dev splits every interaction into three phases, and each phase points at a different suspect, so the first job in INP debugging is to learn which phase is biggest before you open a single file. Input delay is the time from the user's action until the first event handler starts, which makes it a measure of what the main thread was already doing. Processing duration is how long the handlers take to run to completion. Presentation delay is the time from the end of the handlers until the next frame is visible, which covers style, layout and paint:

PhaseWhat it measuresTypical cause, per web.devWhere the culprit script is
Input delayuser action until the first event handler startsscript loading, parsing and compiling, timers, overlapping interactionsnot in the click handler
Processing durationall event handlers running to completiontoo much work in the callbacksthe click handler
Presentation delayend of the handlers until the next frame is visiblelarge DOM, expensive style and layout, rendering lots of HTML from JavaScriptoften no script at all

The first three columns summarize the web.dev INP optimization guide. The last column is my own reading of it, and the rest of this article tests it. The same guide rates 200 ms or less as good and above 500 ms as poor, measured at the 75th percentile of page loads, and says the final INP is the longest observed interaction, sometimes ignoring outliers.

Notice what that means. If the worst interaction on your page is an input-delay problem, profiling your click handlers will find nothing, and you'll spend a day proving they're fine. The same goes for presentation delay: a one-line handler can still be followed by a 200 ms paint, and no profile of your own code will show where it went.

How do you split an interaction into its three phases?

With Event Timing. Each event entry carries startTime, processingStart, processingEnd and a duration that ends at the next paint. MDN documents processingStart - startTime as the delay before the handler starts and processingEnd - processingStart as the handler run time. It also says duration is rounded to the nearest 8 ms, and that durationThreshold defaults to 104 ms with a minimum of 16.

One click produces several entries (pointerdown, pointerup, click), and they share an interactionId. So group by that, take the earliest start and the latest end:

new PerformanceObserver((list) => events.push(...list.getEntries())).observe({
  type: 'event',
  durationThreshold: 16, // the minimum; the default would hide anything under 104 ms
  buffered: true,
})

// per interaction, after grouping entries that share an interactionId:
const inputDelay = group.processingStart - group.start
const processing = group.processingEnd - group.processingStart
const presentation = group.end - group.processingEnd // end = startTime + duration

That's the core of reporter.js in the example. Whichever of the three numbers is largest names the phase to chase. It's rounded, so treat differences under 8 ms as noise.

Which script was running while the click waited?

Event Timing says when. It doesn't say who. For that you want Long Animation Frames. The Chrome documentation describes a long animation frame as one where a rendering update is delayed beyond 50 ms, and lists scripts longer than 5 ms in each frame with invoker, invokerType, sourceURL, sourceFunctionName and sourceCharPosition. The invokerType values are user-callback, event-listener, resolve-promise, reject-promise, classic-script and module-script. Support is Chrome and Edge 123 and later, nothing in Firefox or Safari.

Two cautions from the same page. No API links an INP entry to its frames, so you compare timestamps yourself. And the source fields report a script's entry point, not necessarily the slowest line inside it. sourceCharPosition is a character offset into the file, not a line number. My first version printed it after a colon, like a line number, and the file it pointed into has about a hundred lines.

Pairing a phase with a script is then a small rule, and the whole routine fits in four steps:

  1. Collect every interaction with its three phases, sorted by total time.
  2. Take the slowest one and name its largest phase.
  3. Read the overlapping Long Animation Frame scripts with that phase in mind.
  4. Fix that one script or that one piece of rendering, then measure the same interaction again.

Step 3 is the part that needs code:

const overlapping = frames.filter((f) => f.startTime + f.duration >= g.start && f.startTime <= end)
const scripts = overlapping.flatMap((f) => [...f.scripts])

if (dominant === 'inputDelay') {
  // something that started before the handlers did, and is not a handler
  culprit = longest(scripts.filter((s) => s.startTime < g.processingStart && s.invokerType !== 'event-listener'))
} else if (dominant === 'processing') {
  culprit = longest(scripts.filter((s) => s.invokerType === 'event-listener'))
}
// presentation delay: usually no script to name
Decision map: the largest INP phase picks the suspect, where to look in Long Animation Frames, and the kind of fix
The largest phase decides which script, if any, to look at. This map is my own summary, tested by the five clicks below.

Five clicks, five different bills

The page has five buttons. Playwright 1.62.1 clicks each one with real mouse input in headless Chromium 151.0.7922.34, on an Apple M1 with 16 GB and macOS 26.6.2. Every cell below is the median of seven fresh pages. The handlers use a busy-wait as a stand-in for real work, which I'll come back to.

InteractionWhat is slowINP-style totalInput delayProcessingPresentation
Open menuhandler does nothing, a 320 ms timer job is already running304268033
Expand allone class toggled, 12,000 rows appear21610215
Add to cart1000 style writes, each followed by a layout read192015931
Filtera 150 ms click handler184015033
Likenothing, the control320031

Here's what the reporter printed for the culprits, straight from the run:

filter  processing  event-listener BUTTON#filter.onclick -> onFilterClick @ app.js char 938
menu    inputDelay  user-callback TimerHandler:setTimeout -> syncAnalytics @ app.js char 1494
cart    processing  event-listener BUTTON#cart.onclick -> onCartClick @ app.js char 3113
expand  presentation  (no script)

I ran the whole harness three times, and the totals moved by 8 to 16 ms between full runs: the menu came out at 296, 296 and 304. The phases and the culprits never changed. The Like row is worth a look too. Its 31 ms of presentation delay looks like the floor of this headless setup, so a presentation delay in that range isn't something to chase.

Input delay: the handler is innocent

The menu handler flips one hidden attribute. Profile it and you'll see nothing. So where did 268 ms go? It went to a setTimeout callback called syncAnalytics, which was already running when the click arrived, and the LoAF entry names it as a user-callback with the invoker TimerHandler:setTimeout. It isn't in the file you'd open first.

The web-vitals attribution build agrees. Its longestScript summary says syncAnalytics, and labels the subpart input-delay. In real pages the equivalent is an analytics batch, a hydration pass or a third-party tag. The fix lives there. I split the 320 ms job into ten 32 ms chunks with a yield between them, and the click's input delay fell from 268 ms to 12 ms. The mechanics of that yield, and which kind to pick, are what the yielding article measures.

If the thing in the way is a lazy chunk loading at the wrong moment, the same pattern shows up as a classic-script or module-script entry, which makes code-splitting decisions part of an INP investigation too.

Processing: slow code, and slow layout in disguise

The filter button is the textbook case. onFilterClick runs 150 ms, processing is 150 ms, and the LoAF script entry says so with the function's name. My fix is to update the status text, wait for the browser to paint, and only then do the work:

async function onFilterClick() {
  status.textContent = 'Filtering...'
  await nextFrame() // requestAnimationFrame, then setTimeout
  busy(150)
  status.textContent = 'Filtered 150 rows'
}

The measured total fell from 184 ms to 40 ms. I should be straight about what that did, though. The 150 ms of work still happens, it just happens after the paint, so INP stops counting it. A second click during that window still waits. I didn't measure that second click, and in a real app you'd want the work chunked as well.

The cart button is sneakier. Its code looks cheap: a loop that sets row.style.width. But each iteration reads cart.offsetWidth too, so the browser has to recompute layout every time. LoAF reports forcedStyleAndLayoutDuration per script, and for onCartClick it was 156 ms out of 159 ms. That's 98 percent of the handler spent in layout it didn't know it was asking for. Would you have spotted that in a code review? MDN's PerformanceScriptTiming page defines the field as the time a script spent processing forced layout and style.

The fix is reading once, before the loop. Processing went from 159 ms to 1 ms. Without the forced-layout field I'd have called this "a slow loop" and looked for something to memoize.

Presentation delay: nothing to blame but the page

The Expand button toggles one class. The handler takes under 1 ms, no script is reported, and the page needs 215 ms to get the next frame out. web-vitals has nothing to say about a script here either: no longestScript at all. The cost is style, layout and paint for 12,000 rows that were display: none a moment ago.

I tried the fix everybody reaches for first, content-visibility: auto on the rows with a contain-intrinsic-size. The total went from 216 ms to 128 ms. Real, and not enough. Keeping 60 rows in the DOM and a spacer for the rest took it to 48 ms. My opinion: when presentation delay is the problem and the DOM is big, count nodes before you reach for a CSS property. The honest caveat is that my "60 rows plus a spacer" isn't a finished virtual list. It has no scroll handling, so it shows the gain from a smaller DOM and nothing more.

Before and after bars: menu 304 to 40 ms, list 216 to 48 ms, cart 192 to 40 ms, filter 184 to 40 ms
Each fix targets the largest phase of its own interaction. Median of seven runs.

Which of the five would INP report?

I put all five clicks into one page session and compared what my reporter and the web-vitals attribution build said about it, to see whether both tools would name the same interaction as the worst one:

RankInteractionTotal (ms)Phases (delay + processing + presentation)
1menu272269 + 1 + 2
2expand2241 + 1 + 223
3cart1760 + 161 + 15
3filter1760 + 150 + 25
5like251 + 1 + 24

web-vitals reported INP of 272 ms, target #menu, longest script syncAnalytics in the input-delay subpart. Both tools picked the same interaction.

The slowest interaction in my five-click session was the one whose click handler did nothing: it waited 269 ms behind a timer job.

Share this Post on X Bluesky

That's the result I'd have bet against, and it's why I'd argue against the usual advice to profile your click handlers first. Start from the attribution data, not from the code you suspect. Two of my four slow interactions were really in their handlers. The other two weren't, and one of those was the worst of the lot.

What changes when you collect this from real users?

The same data, in the field, comes from the attribution build. The web-vitals README lists interactionTarget, inputDelay, processingDuration, presentationDelay, loadState and longAnimationFrameEntries. In the 6.2.3 build I ran, the typings also include longestScript, totalScriptDuration, totalStyleAndLayoutDuration and totalPaintDuration. This is a trimmed version of what my harness records:

import { onINP } from 'web-vitals/attribution'

onINP((metric) => {
  const a = metric.attribution
  report({
    value: metric.value,
    target: a.interactionTarget,
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    loadState: a.loadState,
    script: a.longestScript?.entry.sourceFunctionName,
    subpart: a.longestScript?.subpart,
  })
})

The callback ran in my harness. The report() that ships it to your backend, a navigator.sendBeacon call for instance, I didn't write. Three things I learned the hard way:

  • It doesn't answer immediately. My first harness read web-vitals right after each click and got nothing. The library waits for an idle callback, up to one second, or for the page to be hidden. I now send a visibilitychange with the state forced to hidden, which is what a user leaving the tab does.
  • The default threshold is 40 ms. The README says events faster than durationThreshold aren't reported. I lowered it to 16 to see the control click. In production the default is fine.
  • reportAllChanges is for debugging. The README says it's not recommended in production, so don't ship it just because it made your test pass.

Beyond that, how you aggregate matters more than how you collect. Group by interactionTarget and by which phase is largest, and sort by 75th-percentile INP rather than by count. One button that's awful for a few users and another that's mildly slow for everybody can rank either way, and only the grouped numbers will tell you which. The field guide on web.dev lists INP support in Chrome and Edge 96, Firefox 144 and Safari 26.2, so the phase fields should arrive from all three engines, while script names come only from Chromium. I tested Chromium only.

In React, startTransition helps by letting a click interrupt a long render: in the yielding article a click waited 203 ms behind a plain setState and 5 ms behind a transition. I wouldn't expect it to shrink presentation delay, but I haven't measured that. The React Profiler only sees React's own render and commit work, so it can't split an interaction into these phases either. For the wider field-and-lab setup, the Core Web Vitals guide covers LCP and CLS alongside INP.

What this doesn't prove

One machine, one headless browser and handlers made of busy-waits limit what these numbers prove, and the busy-waits are clean on purpose: real code allocates, touches the DOM and calls five libraries. LoAF drops scripts under 5 ms, so a handler that dies from a thousand small cuts will show up as unattributed time, and my harness has no such case. I used mouse clicks only, no keyboard, no touch, and no iframes (the Chrome docs say cross-origin iframes are excluded from script attribution). I ran no Firefox or Safari.

reporter.js also does less than the library. It groups by interactionId only, while the library groups events by the frame they were presented in. A comment in the web-vitals source describes one case that matters there, a long pointerover handler still running when the click arrives, and the library clamps the input delay so it can't go negative. I didn't test that case. For anything you ship, use the library and keep my 70 lines for the console.

Which of your own slowest interactions would still be your slowest if you sorted them by phase instead of by handler? The next time INP goes red, I'd ask that question before opening a single component. If the answer is "the part where the browser had to paint", the ViewTransition measurements show what a transition changes in that step, and the event loop explainer covers the ordering rules underneath all three phases.

Frequently Asked Questions

How do I find which interaction is hurting my INP?
Collect the INP attribution from real users with the web-vitals attribution build and read interactionTarget together with the three phase fields: inputDelay, processingDuration and presentationDelay. In the lab, group Event Timing entries by interactionId and compute the same three numbers yourself. The target tells you which element, and the largest phase tells you which kind of fix to look for. In my five-click session the worst interaction was a menu button whose handler took about 1 ms.
What is the difference between input delay, processing duration and presentation delay?
Input delay runs from the user's action until your event handlers start, so it measures what the main thread was already busy with. Processing duration is the time your handlers take. Presentation delay runs from the end of the handlers until the next frame is painted, which covers style, layout and paint. In my harness the three biggest cases were 268 ms of input delay, 159 ms of processing and 215 ms of presentation delay.
Why does my click handler look fast but INP is still slow?
Either the click waited before the handler started, or the browser needed a long time to paint after it finished. Neither shows up in a profile of the handler. A timer task that was already running made my empty menu handler wait 268 ms, and toggling one class on a 12,000-row list cost 215 ms of presentation delay with no script involved.
Does the Long Animation Frames API work in Firefox and Safari?
Not at the time of writing. The Chrome documentation lists Chrome and Edge 123 and later, and no Firefox or Safari support. INP itself is supported more widely, so the web-vitals attribution build still reports the three phases there, but the longAnimationFrameEntries array stays empty and you lose the script names.