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-rolledPerformanceObserveroften 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:
- 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.
- Processing duration – the time all event handlers of the interaction take. A single click fires
pointerdown,pointerupandclick, and handlers for all events processed in the same frame count here. - Presentation delay – from the end of the handlers until the next frame is shown. This includes
requestAnimationFrame,ResizeObserverandIntersectionObservercallbacks, 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,
onINPwithreportAllChanges) – 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 ( | 16 | 1 | 9 | 7 |
Update UI, defer the rest with | 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 | 632 | 1 | 52 | 579 |
Same, rows in | 112 | 1 | 73 | 37 |
React: update a 3,000-row list | 536 | 2 | 111 | 423 |
React: list update in | 112 | 2 | 82 | 27 |
React: | 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.
| Input delay | Processing | Presentation | |
|---|---|---|---|
| 150 ms foreign task | 130 ms | 8 ms | 6 ms |
| 150 ms handler | 2 ms | 157 ms | 9 ms |
| Insert 3,000 rows | 1 ms | 52 ms | 579 ms |
| Same + content-visibility | 1 ms | 73 ms | 37 ms |
| React, sync | 2 ms | 111 ms | 423 ms |
| React, transition + memo | 2 ms | 28 ms | 29 ms |
| Vue | 4 ms | 128 ms | 562 ms |
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. fetchcallbacks, 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
requestAnimationFramecalls.
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. TherequestAnimationFramecallback runs before paint, and thesetTimeoutinside 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, unlikesetTimeout, 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:
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 withcontent-visibility: autoandcontain-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:
startTransitionmarks 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.memoon 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. Withmemo: 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
- 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,presentationDelayand the page load state at interaction time. - 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
longAnimationFrameEntriesand the longest script aslongestScript, together with the subpart it ran in. It's the fastest way to tell your code from third-party code. - 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:
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, orawait 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
startTransitionand wrap heavy subtrees inmemo; 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
- 01Interaction to Next Paint (INP)web.dev · 2025
- 02Optimize Interaction to Next Paintweb.dev · 2025
- 03Event Timing APIW3C
- 04Optimize long tasksweb.dev · 2024
- 05Use scheduler.yield() to break up long tasksChrome for Developers · 2025
- 06Optimize input delayweb.dev
- 07How large DOM sizes affect interactivityweb.dev
- 08Long Animation Frames APIChrome for Developers
- 09INP breakdownChrome for Developers · 2025
- 10web-vitalsGoogleChromeversion 6.2.3
- 11startTransitionReact
- 12Automatic batching for fewer renders in React 18React Working Group · 2021
- 13Reactivity FundamentalsVue.js
- 14PerformanceVue.js
- 15Performance – Web Almanac 2025HTTP Archive · 2025
- 16INP performance analysis: RUMvision dataRick Viscomi · Web Performance Calendar · 2023