Skip to content

14 min read

INP breakdown: where the milliseconds of a click go

How to improve INP: I measured 14 scenarios in Chrome and split interaction latency into input delay, processing duration and presentation delay. Why inserting 3,000 rows costs 580 ms after your code has already finished, and what actually fixes each subpart.

To improve INP, first find out which of three subparts is eating the time: before the browser can start handling the click (input delay), while event handlers run (processing duration), or while the browser computes and paints the next frame (presentation delay). Each subpart has its own fixes, and the wrong fix does nothing. I built a test page with 14 scenarios and measured every subpart in Chrome 153 with a 4× CPU slowdown. The most telling result: inserting 3,000 list rows cost 632 ms of INP, of which the click handler ran for 52 ms. The rest went to style and layout after the code had already finished. Speeding up that handler is pointless; content-visibility: auto brought INP down to 112 ms.

How INP is calculated

INP (Interaction to Next Paint) is the Core Web Vitals metric for responsiveness. The browser observes mouse clicks, taps and key presses over the whole life of the page; hover, scroll and zoom don't count. The latency of one interaction runs from the user's action to the next frame in which the browser can show a result. Network requests and later async updates are not included – only the first chance to show any feedback.

The slowest interaction of the visit is reported, except that one highest interaction is ignored for every 50, so a random hiccup on a page with hundreds of clicks doesn't decide the score. Across visits, the 75th percentile is taken separately for mobile and desktop:

INP at the 75th percentile

Rating

up to 200 ms

good

200–500 ms

needs improvement

over 500 ms

poor

Two technical details explain odd numbers in reports:

  • Durations are rounded to 8 ms. The Event Timing spec, which INP is built on, requires it. That's why all values in my tables are multiples of 8 and a trivial click shows 8 or 16 ms.
  • The API skips events shorter than 104 ms by default. You can lower the threshold with durationThreshold, but not below 16 ms. The web-vitals library handles this; a hand-rolled PerformanceObserver often doesn't.

Chrome usage data shows that 90% of a user's time on a page is spent after it loads. That's why INP covers the whole visit, while its predecessor FID only looked at the delay before the first input.

The three INP subparts

web.dev and Chrome tooling split one interaction's latency into three subparts that add up to the total:

  1. Input delay – from the user's action until event handlers start running. Nothing of yours runs yet: the main thread is busy with something else.
  2. Processing duration – the time all event handlers of the interaction take. A single click fires pointerdown, pointerup and click, and handlers for all events processed in the same frame count here.
  3. Presentation delay – from the end of the handlers until the next frame is shown. This includes requestAnimationFrame, ResizeObserver and IntersectionObserver callbacks, style recalculation, layout, paint and compositor work.

Each iframe has its own main thread. INP is reported for the page including slow interactions inside iframes, but JavaScript APIs can't see into them, so CrUX and your own RUM data may disagree on pages with embedded widgets.

The test page: how I measured INP subparts

Test conditions
  • Google Chrome for Testing 153.0.8010.12, --headless=new, driven by playwright-core 1.64.1.
  • A two-core Intel Xeon 2.1 GHz server, 4× CPU throttling via the DevTools Protocol (Emulation.setCPUThrottlingRate), 412 × 915 viewport.
  • Subparts computed by web-vitals 6.2.3 (attribution build, onINP with reportAllChanges) – the same way a RUM tool would see them.
  • Real mouse events through the DevTools Protocol, without waiting for a frame before the click.
  • 15 page loads with one click per scenario. The table shows medians for INP and for each subpart separately, so subparts may not add up to INP exactly.
  • The synthetic "heavy work" is a loop that holds the thread for a fixed wall-clock time, so throttling doesn't change it. Real framework and browser work is throttled.
  • React 19.3.1, Vue 3.5.44, bundled with esbuild 0.28.2 in production mode.

This is a lab measurement on a server, not a phone. Absolute numbers on a real low-end device will differ; what matters is which subpart takes the time and how much a fix changes the result.

Scenario

INP

Input delay

Processing

Presentation

Trivial handler, idle thread

16

2

6

6

Trivial handler, a 150 ms foreign task running

144

130

8

6

Same task split into 10 ms chunks

24

9

8

5

Handler runs for 150 ms

168

2

157

9

Update UI, defer the rest past the frame (requestAnimationFrame + setTimeout)

16

1

9

7

Update UI, defer the rest with scheduler.yield()

16

2

9

7

1,000 elements: interleaved size writes and reads

3632

2

3600

37

Same, all writes first, then all reads

72

2

39

28

Insert 3,000 rows via innerHTML

632

1

52

579

Same, rows in content-visibility: auto sections

112

1

73

37

React: update a 3,000-row list

536

2

111

423

React: list update in startTransition

112

2

82

27

React: startTransition plus memo on the list

56

2

28

29

Vue: update a 3,000-row list

664

4

128

562

All values are my own measurements in milliseconds, median of 15 runs.

Where a click's time goes: INP subparts by scenarioMedian INP subparts in Chrome 153 with 4× CPU throttling. The interleaved size reads and writes scenario (3,632 ms) is left out to keep the scale readable.
Where a click's time goes: INP subparts by scenario
Input delayProcessingPresentation
150 ms foreign task130 ms8 ms6 ms
150 ms handler2 ms157 ms9 ms
Insert 3,000 rows1 ms52 ms579 ms
Same + content-visibility1 ms73 ms37 ms
React, sync2 ms111 ms423 ms
React, transition + memo2 ms28 ms29 ms
Vue4 ms128 ms562 ms

Source: own measurement, web-vitals 6.2.3

Input delay: the button isn't the culprit

In the second scenario the click handler is trivial, yet INP is 144 ms: for 130 ms the click waited for someone else's task to finish. Reading the handler's code won't reveal the cause. Chrome DevTools' "INP breakdown" insight says it directly: with a large input delay, the interaction may be a victim rather than the cause.

What typically occupies the main thread at click time, per web.dev:

  • Script evaluation during load. Every script is parsed, compiled and executed, and a big bundle becomes a long task. So the dead code from failed tree shaking hurts not only loading but the INP of early clicks. SSR hydration falls into the same bucket – see the article on hydration mismatch.
  • Timers, especially recurring setInterval, including those of third-party analytics and widgets.
  • fetch callbacks, when many arrive at once.
  • A previous interaction that hasn't painted its frame yet: fast typing into an autocomplete field is the classic case.
  • JavaScript animations with many requestAnimationFrame calls.

A long task is any task over 50 ms. Input delay is fixed by breaking up other tasks, not by optimizing the handler. In the third scenario the same 150 ms of work was split into 10 ms chunks with await scheduler.yield() between them, and input delay dropped from 130 to 9 ms with the same total work. The click waited only for the current chunk.

For third-party scripts you don't control, the options are blunter: load them later, after the first interaction or via requestIdleCallback, and drop the ones that don't pay for themselves. For your own frequent input handlers, web.dev recommends debouncing and cancelling stale requests with AbortController.

Processing duration: the handler does too much before the frame

A handler that runs for 150 ms gives an INP of 168 ms, almost all of it processing. It's the most obvious case and, in the field, the most common one among slow interactions: in RUMvision data for October–November 2023 (6.4 million page views), half of mobile interactions with INP over 200 ms had at least 120 ms of processing.

Feedback first, everything else later

INP measures time to the next frame, not to the end of all work. Before the frame, do only what the user must see right away: toggle the button, show a spinner, insert the character. Totals, saving, analytics and secondary widgets can wait. web.dev uses a text editor as the example: the text box updates immediately, while word count, spell check and saving are deferred.

On my test page both deferral techniques gave the same result: INP of 16 ms instead of 168, with the 150 ms of work still done.

  • requestAnimationFrame + setTimeout. The requestAnimationFrame callback runs before paint, and the setTimeout inside it schedules the work in a new task after the frame. It looks odd but works in every browser.
  • await scheduler.yield(). Yields the thread, and the continuation is queued with boosted priority: it runs before other tasks of the same priority, unlike setTimeout, which goes to the back of the queue. In my runs Chrome 153 painted the frame before the continuation. The docs don't explicitly promise a paint between the yield and the continuation, so verify on your own flows.

A minimal scheduler.yield() version:

JavaScript
button.addEventListener('click', async () => {
  showSelected()          // what the user must see right away
  await scheduler.yield()
  recalcTotals()          // everything else
  sendAnalytics()
})

scheduler.yield() ships in Chrome and Edge 129 and Firefox 142; Safari doesn't support it. Safari needs a setTimeout fallback or scheduler-polyfill. Note that after five levels of nesting, browsers clamp each setTimeout to at least 5 ms. isInputPending(), once recommended for checking pending input, is no longer recommended by web.dev: just yield.

Forced synchronous layout

The most expensive scenario: 1,000 elements, each gets a new width and its offsetWidth is read right after. INP: 3.6 seconds. Reading a size after writing a style forces the browser to recompute layout immediately, inside the handler, 1,000 times in a row – layout thrashing. Doing all writes first and all reads after computes layout once and drops INP to 72 ms. Note that this browser layout work lands in processing, not presentation, because it happens inside the handler. The DevTools Performance panel marks such spots with a red triangle on Recalculate Style and Layout.

Presentation delay: JavaScript is done, the frame isn't

Inserting 3,000 rows via innerHTML: 632 ms of INP, 52 ms of handler, 579 ms of style, layout and paint after the handler. There's hardly any code to optimize.

Presentation delay grows with the amount of DOM the browser must recompute after a change. Lighthouse warns from 800 DOM nodes and flags over 1,400 as excessive. What helps:

  • Less DOM at once. Don't render hidden content upfront – add it on open – and show long lists in batches or virtualize them.
  • content-visibility: auto. The browser skips layout and paint for off-screen elements until they approach the viewport. I wrapped rows into sections of 100 with content-visibility: auto and contain-intrinsic-size: INP fell from 632 to 112 ms, presentation delay from 579 to 37 ms. Processing grew from 52 to 73 ms, in my code because of splitting the markup into sections. The property has side effects on the scrollbar, find-in-page and anchors – a separate article will cover them.
  • Simple selectors and styles. Complex selectors make every style recalculation more expensive.

Server-sent HTML arrives as a stream that the browser parses in chunks, yielding in between. HTML created on the client gets no such chunking: everything inserted in one task is parsed and laid out at once.

React and Vue: where the framework render lands

A common belief is that "React and Vue update the DOM asynchronously, so their render doesn't block the click." The measurement says otherwise: the framework's render in response to a click lands in processing duration, before the frame.

Vue's docs say DOM updates are buffered until the "next tick" so each component updates only once. For discrete events like clicks, React flushes updates synchronously in a microtask, according to a React team member in the React 18 working group discussion. Deferred doesn't mean after the frame: on my page, updating 3,000 rows took 111 ms of processing in React and 128 ms in Vue, followed by 423 and 562 ms of presentation delay for layout and paint of the changed rows.

What worked in React:

  • startTransition marks an update as non-urgent: React renders it in the background, can interrupt it for new input, and the click gets its frame sooner. With the button updated immediately and the list in a transition, INP dropped from 536 to 112 ms. A documented limitation: transitions can't be used for updates that control a text input.
  • memo on the heavy subtree. Without it, the urgent button update still re-renders all 3,000 rows in the virtual DOM even though nothing changed there – 82 ms of processing. With memo: 28 ms, INP 56 ms.

Vue has no direct startTransition equivalent. Its performance guide focuses on smaller updates: stable props so children don't re-render needlessly, v-memo and v-once for large subtrees, list virtualization, and shallowRef for large immutable data. Heavy non-urgent work can be deferred as in plain JavaScript: change what's needed for feedback, then await scheduler.yield(), then the rest.

How to find the guilty subpart on your site

  1. Field data. PageSpeed Insights shows INP from CrUX, Chrome's real-user dataset, but not which click is slow or why. For that you need your own RUM: the web-vitals attribution build reports the target selector, inputDelay, processingDuration, presentationDelay and the page load state at interaction time.
  2. The culprit script. Chrome and Edge 123+ support the Long Animation Frames API (LoAF). It reports frames delayed over 50 ms and the scripts inside them: source URL, function name, character position, and how much time went to forced layout. web-vitals attaches intersecting frames as longAnimationFrameEntries and the longest script as longestScript, together with the subpart it ran in. It's the fastest way to tell your code from third-party code.
  3. Lab. Record the interaction in the Chrome DevTools Performance panel with CPU throttling and open the "INP breakdown" insight. Also click while the page is loading: that's when the thread is busiest and input delay shows up. In the RUMvision data, 42% of mobile INP interactions happened within the first 10 seconds of the visit.

Minimal code to collect subparts:

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

onINP(({ value, attribution: a }) => {
  sendToAnalytics({
    inp: value,
    target: a.interactionTarget,
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    script: a.longestScript?.entry.sourceURL,
  })
})

One caveat. The browser groups events by frame, so when several interactions are processed in one frame, one interaction's handler can show up as another's input delay. In a W3C discussion, Barry Pollard of the Chrome team advises reproducing the interaction itself in the lab when presentation delay is high. With high input delay, look for whatever else was occupying the thread.

What INP looks like across the web

According to the Web Almanac 2025 (CrUX data, July 2025), 77% of sites have good INP on mobile and 97% on desktop. On mobile, popular sites do worse: only 63% of the top 1,000 pass. Secondary pages trail home pages on mobile: 69% vs 80%. Median mobile Total Blocking Time – a lab metric of main-thread blocking during load – grew from 1,209 to 1,916 ms in a year.

Public subpart data with a described methodology is old: in RUMvision's 2023 sample, mobile medians were 25 ms input delay, 39 ms processing and 53 ms presentation delay. In a typical click rendering weighs most; in slow ones, handlers do. I couldn't find more recent open data on subparts.

Key takeaways

  • INP is time to the next frame, not to the end of the work. Defer everything not needed for instant feedback: requestAnimationFrame + setTimeout, or await scheduler.yield() with a Safari fallback.
  • Find the subpart first: web-vitals attribution and LoAF in the field, the "INP breakdown" insight in DevTools. Otherwise you can spend a week optimizing a handler that isn't to blame.
  • High input delay is fixed by breaking up long tasks, shipping less JavaScript at load and deferring third-party scripts – not by touching the handler.
  • High presentation delay means too much DOM and style work after the change. content-visibility: auto, batched rendering and virtualization help: 632 ms became 112 on my page.
  • React and Vue render before the frame. In React, move heavy updates into startTransition and wrap heavy subtrees in memo; in Vue, shrink the update.
  • Never read element sizes right after changing styles in a loop: 1,000 such pairs cost 3.6 seconds.

Sources

  1. 01Interaction to Next Paint (INP)web.dev · 2025
  2. 02Optimize Interaction to Next Paintweb.dev · 2025
  3. 03Event Timing APIW3C
  4. 04Optimize long tasksweb.dev · 2024
  5. 05Use scheduler.yield() to break up long tasksChrome for Developers · 2025
  6. 06Optimize input delayweb.dev
  7. 07How large DOM sizes affect interactivityweb.dev
  8. 08Long Animation Frames APIChrome for Developers
  9. 09INP breakdownChrome for Developers · 2025
  10. 10web-vitalsGoogleChromeversion 6.2.3
  11. 11startTransitionReact
  12. 12Automatic batching for fewer renders in React 18React Working Group · 2021
  13. 13Reactivity FundamentalsVue.js
  14. 14PerformanceVue.js
  15. 15Performance – Web Almanac 2025HTTP Archive · 2025
  16. 16INP performance analysis: RUMvision dataRick Viscomi · Web Performance Calendar · 2023