Diagnosing a bad INP

"Our INP is bad. Diagnose it."

What it is

Interaction to Next Paint measures the time from a user interaction (click, tap, key press) to the next frame the browser paints showing the result. It replaced First Input Delay as a Core Web Vital in March 2024, and the replacement is the point: FID measured only the delay before the handler started. INP measures the whole thing.

User clicks
   |
   |-- INPUT DELAY -------|  main thread busy with something else
   |                      |
   |    PROCESSING TIME --|  your event handlers run
   |                      |
   |    PRESENTATION -----|  style, layout, paint of the next frame
   |                      |
   +---------------------->  frame visible.  INP = the whole span

The reported value is roughly the worst interaction on the page, with an allowance for outliers: below 50 interactions it is the single worst, and above that one interaction is discounted per 50, so a page with 150 interactions reports approximately the 98th percentile.

Thresholds: good under 200 ms, needs improvement 200 to 500 ms, poor above 500 ms, assessed at the 75th percentile of real user sessions.

Commonly confused with a JavaScript performance problem. Presentation delay, the style-layout-paint work after your handler returns, is frequently the largest of the three phases and no amount of handler optimisation touches it.

Also commonly confused with FID. A page can have excellent FID and terrible INP, because FID only measured the first interaction and only its delay. Teams that "already fixed FID" often have the worst INP problems.

The problem it solves

INP exists because FID flattered sites. Measuring only the delay before the first handler runs meant a page could score well while every interaction after the first took half a second to show anything. Users experience the delay to the visible result, and that is what INP measures.

The concrete failure it surfaces: a single long task blocks the main thread, and every interaction that lands during it waits. The browser is single-threaded for JavaScript, style, layout and paint. A 400 ms script means an interaction arriving at its start waits 400 ms before your handler even begins.

Mechanics

Measuring it properly

Field data first. Lab tools cannot measure INP meaningfully because it depends on what users actually do.

import { onINP } from 'web-vitals';

onINP((metric) => {
  const entry = metric.entries[0];
  navigator.sendBeacon('/rum', JSON.stringify({
    value: metric.value,
    rating: metric.rating,
    // The attribution build gives you the three phases, which is
    // the whole diagnosis. Without them you know it is slow, not why.
    inputDelay:   metric.attribution?.inputDelay,
    processing:   metric.attribution?.processingDuration,
    presentation: metric.attribution?.presentationDelay,
    target:       metric.attribution?.interactionTarget,  // CSS selector
    type:         entry?.name,                            // click, keydown...
    loadState:    metric.attribution?.loadState,
  }), { type: 'application/json' });
}, { reportAllChanges: false });

The three-phase split is the diagnosis. Everything below follows from which phase dominates.

Long tasks, for the input-delay case: what is blocking the thread?
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      // attribution tells you which script, which is what you need
      console.log(entry.duration, entry.attribution?.[0]?.containerSrc);
    }
  }
}).observe({ type: 'longtask', buffered: true });

Phase 1: input delay dominant

The main thread was busy. Causes, in rough order of frequency:

Third-party scripts. Analytics, tag managers, chat widgets, A/B testing snippets. A tag manager that loads 12 vendor scripts synchronously is the single most common cause of bad INP in the wild, and it is usually not owned by the engineering team, which is why it survives.

Hydration. A server-rendered React or Vue page must attach event listeners to the whole tree before anything is interactive. On a large page that is a multi-hundred- millisecond task, and interactions during it queue.

Long tasks from your own code. Parsing a large payload, running a big map over thousands of items, or rendering a large list synchronously.

The fixes:

// Yield to the main thread so a queued interaction can run.
// scheduler.yield() (Chrome 129+) preserves task ordering; the
// setTimeout fallback goes to the back of the queue.
async function processInChunks(items, fn) {
  for (let i = 0; i < items.length; i++) {
    fn(items[i]);
    if (i % 50 === 0) await yieldToMain();
  }
}

function yieldToMain() {
  if ('scheduler' in globalThis && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise((r) => setTimeout(r, 0));
}
<!-- Third parties: defer, or move off the main thread entirely. -->
<script src="/analytics.js" defer></script>

Partytown moves third-party scripts to a web worker, which is a heavier hammer and occasionally breaks scripts that need direct DOM access, but for a tag manager it is often the fastest large win available.

Phase 2: processing dominant

Your handler is slow. This is the phase people assume is the problem and it often is not, but when it is:

Synchronous state updates that re-render a large tree. A click that calls setState on a component with 3,000 children re-renders all of them before the browser can paint.

Expensive work in the handler. Sorting, filtering, formatting, or a synchronous layout read that forces the browser to flush pending style work.

The fixes, in order of leverage:

// 1. Paint the feedback first, do the work after. This is the highest-
//    leverage change in most cases, because INP measures the time to the
//    NEXT PAINT, not the time to complete the work.
button.addEventListener('click', async () => {
  setPending(true);                    // cheap visual feedback
  await yieldToMain();                 // let the browser paint it
  const result = expensiveComputation(); // now do the work
  setResult(result);
});
// 2. Mark the expensive update non-urgent so React can interrupt it.
import { useTransition } from 'react';

function Search() {
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState('');

  return (
    <input onChange={(e) => {
      setQuery(e.target.value);              // urgent: the input must
                                             // update on the next frame
      startTransition(() => {
        setResults(filterHugeList(e.target.value));  // interruptible
      });
    }} />
  );
}
// 3. Layout thrashing: batch reads, then writes. Interleaving them
//    forces a synchronous layout on every iteration.
const heights = elements.map((el) => el.offsetHeight);   // all reads
elements.forEach((el, i) => { el.style.height = heights[i] + 'px'; }); // writes

Phase 3: presentation dominant

The handler finished quickly, and the browser then spent a long time on style, layout and paint. This is the phase teams miss entirely because it looks like nothing in the profiler's JavaScript flame chart.

Causes:

A very large DOM. Style recalculation cost scales with the number of affected elements. A 12,000-node DOM makes every interaction expensive regardless of what your code does. Google's guidance is to keep it under about 1,400 nodes, though the real number depends on selector complexity.

Expensive CSS selectors and deep descendant rules, which make style recalc walk more of the tree than necessary.

Layout-triggering property changes. Animating width, top or left forces layout on every frame; transform and opacity do not.

Missing content-visibility. Off-screen content still costs layout and paint.

/* Skip rendering work for off-screen sections entirely. */
.card-list-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;  /* prevents scrollbar jumping */
}

/* Isolate a subtree so changes inside it cannot invalidate layout outside. */
.widget { contain: layout style paint; }

Virtualisation for long lists is the structural fix: render 30 rows instead of 3,000 and the DOM size problem disappears.

A worked example

An e-commerce search results page. Field INP at p75 is 620 ms, which is "poor". The team has already optimised their React handlers and INP did not move, which is the signal that the assumption was wrong.

Step 1: get the phase breakdown from RUM, segmented by interaction target.

Interaction target        p75 INP   input delay   processing   presentation
-------------------------------------------------------------------------
button.filter-facet        890 ms      520 ms        90 ms        280 ms
input.search-box           410 ms      180 ms       150 ms         80 ms
a.product-card             240 ms      190 ms        20 ms         30 ms
button.add-to-cart         180 ms       60 ms        90 ms         30 ms

Immediately two things are visible. Input delay dominates almost everything, which means the main thread is busy rather than the handlers being slow. And the facet button also has a large presentation cost, which is a second, separate problem.

Step 2: find what is occupying the main thread.

Long tasks during the first 10 seconds, from the longtask observer:

  duration   attribution
  ---------  -----------------------------------------
    412 ms   googletagmanager.com/gtm.js
    260 ms   (hydration: our bundle, ReactDOM.hydrateRoot)
    180 ms   cdn.chatvendor.com/widget.js
    140 ms   googletagmanager.com/gtm.js  (second wave)
     95 ms   our bundle: buildFacetIndex()

Nearly 700 ms of the blocking comes from third-party scripts loaded through the tag manager, which the marketing team owns and which nobody had looked at, because the engineering team was profiling their own code.

Step 3: fix in order of measured cost, not of familiarity.

1. Tag manager: defer the container, and move the three heaviest
   vendor tags into Partytown (a web worker).
     Expected: removes ~550 ms of main-thread blocking during load.

2. Chat widget: load on first scroll or after 5 s idle, whichever
   comes first. It was blocking initial interactivity to serve
   roughly 3% of sessions.
     Expected: removes 180 ms.

3. Hydration: switch the results grid to a client component island
   rather than hydrating the entire page tree.
     Expected: 260 ms -> ~90 ms.

4. buildFacetIndex(): chunk it with scheduler.yield() every 50 items.
     Expected: 95 ms -> under 20 ms per chunk.

5. Facet button presentation delay (280 ms): the facet click re-renders
   a 4,200-node results grid. Add virtualisation to the grid and
   content-visibility: auto to off-screen facet groups.
     Expected: DOM nodes 4,200 -> ~900; presentation ~280 ms -> ~60 ms.

Step 4: verify in the field, not in the lab, because INP is a distribution over real interactions and a lab run with one click proves nothing. Two weeks of RUM after the change, comparing p75 by interaction target.

The lesson to state out loud: the team had spent a sprint optimising handlers, which was 90 ms of a 890 ms problem. The phase breakdown is the diagnosis, and without it you optimise whichever phase you happen to be able to see.

Production evidence

Google made INP a Core Web Vital in March 2024, replacing FID, and published the rationale: FID's correlation with user-perceived responsiveness was weak because it measured only the delay of the first interaction.

The web-vitals library's attribution build exists specifically to expose the three-phase split and the interaction target, which is direct evidence that Chrome's own team considers phase attribution the necessary diagnostic.

Chrome's scheduler.yield() shipped in Chrome 129 after the ecosystem converged on setTimeout(0) as a yield mechanism; the difference is that scheduler.yield() continues at the front of the queue rather than the back, so yielding does not lose your place to unrelated tasks.

Partytown (from Builder.io) exists because third-party scripts are the dominant main-thread cost on a large fraction of commercial sites, and moving them to a worker was worth building a proxy-based DOM shim to achieve.

content-visibility is a CSS Containment specification feature specifically for skipping rendering work on off-screen content, and Chrome's own documentation reports large rendering improvements on long pages.

The debate

The case for treating INP as a top priority: it is the metric that most directly represents "does this site feel responsive", it is a ranking signal, and unlike LCP it measures the whole session rather than load. For an interactive application it is the right thing to optimise.

The case against over-indexing on it: INP is roughly a worst-case measurement, so a single rare pathological interaction can dominate a page's score while the typical experience is fine. Chasing the number can lead to optimising an interaction that almost nobody performs, and the framework-level fixes (islands, virtualisation, selective hydration) are large architectural changes to move a metric.

My position: diagnose by phase before doing any work, and expect the answer to be third-party scripts and DOM size rather than your handlers. In practice the ordering that pays is: remove or defer third-party main-thread work first, because it is usually the largest single block and it is not load-bearing for your product; then reduce hydration cost; then shrink the DOM, because presentation delay is invisible in a JavaScript profiler and is frequently the second-largest phase; and only then optimise handlers, which is where teams start and where the least time usually is.

The one thing I would push back on is optimising INP without segmenting by interaction target. The aggregate number tells you a problem exists. The per-target breakdown tells you which interaction, and those usually have different causes requiring different fixes, so a single aggregate leads to a single fix that moves nothing.

Follow-up Q&A

"Our INP is bad. Diagnose it." I would start with field data, not lab, because INP depends on what users actually do. Specifically the web-vitals attribution build, which gives me the three phases and the interaction target. Then segment: p75 INP by target element, split into input delay, processing and presentation. That table is the diagnosis. If input delay dominates, the main thread is busy and I look at long tasks and their attribution, which usually points at third-party scripts or hydration. If processing dominates, it is my handlers. If presentation dominates, it is DOM size and style recalculation, which is invisible in a JavaScript profiler.

"Why did INP replace FID?" FID measured only the delay before the first interaction's handler started, so a page could score well while every interaction after the first took half a second to produce a visible result. INP measures the full span from interaction to next paint, across all interactions, and reports approximately the worst one. Teams that "already fixed FID" frequently have the worst INP problems, because they optimised a metric that was not measuring what users feel.

"What's the highest-leverage single fix?" Usually deferring or worker-isolating third-party scripts, because a tag manager pulling in a dozen vendor tags synchronously is the largest single block of main-thread time on a large fraction of commercial sites, and it is not load-bearing for the product. After that, painting feedback before doing the work: set a pending state, yield to let the browser paint, then compute. Since INP measures time to the next paint rather than time to complete the work, that alone can take an interaction from 400 ms to 30 ms without making anything faster.

"Presentation delay is high. What causes that?" Style recalculation, layout and paint after the handler returns, and its cost scales with how much of the DOM is affected. So: a very large DOM, expensive descendant selectors, animating layout-triggering properties like width or top instead of transform, and off-screen content that is still being laid out. The fixes are virtualisation for long lists, content-visibility: auto for off-screen sections, and CSS containment to stop a change in one subtree invalidating layout elsewhere.

"How does React's useTransition help?" It marks an update as non-urgent, so React can interrupt rendering it to handle a more urgent update, like the keystroke that is still arriving. The pattern is to set the input value urgently, so the field updates on the next frame, and to wrap the expensive derived work in startTransition. Without it a search-as-you-type field re-renders the whole result list synchronously on every keystroke and the input feels laggy even though the handler is cheap.

"Can you diagnose INP in Lighthouse?" Not meaningfully. Lighthouse can report Total Blocking Time, which correlates with input delay, and it will flag long tasks, but INP depends on which interactions users perform and where they land relative to the blocking work. A lab run clicking one button proves nothing about the p75 of real sessions. Lab tools tell you what could block; field data tells you what did.

Common misconceptions

"INP is a JavaScript problem." Presentation delay is often the largest phase, and it is style, layout and paint. It does not appear in a JavaScript flame chart.

"We fixed FID, so we're fine." FID measured the first interaction's delay only. The two metrics can diverge completely.

"INP is an average." It is approximately the worst interaction, with one outlier discounted per 50 interactions. One bad interaction can define your score.

"Optimise the handler." Processing time is frequently the smallest of the three phases. Measure first.

"setTimeout(0) is the way to yield." It works, and it puts you at the back of the task queue, so unrelated work can jump ahead. scheduler.yield() resumes at the front.

Interview delivery note

Lead with the phase split, because that is the actual diagnostic method and most candidates go straight to "profile the handler": "INP has three phases: input delay while the main thread is busy, processing while my handler runs, and presentation while the browser does style, layout and paint. The first thing I'd get is field data from the web-vitals attribution build, broken down by those three phases and segmented by interaction target. That table is the diagnosis."

Then give the prior, because it shows you have done it: "and my expectation is that input delay dominates and the cause is third-party scripts, usually a tag manager loading a dozen vendor tags. In the case I worked, the team had spent a sprint optimising handlers, which was ninety milliseconds of an eight-hundred-and-ninety millisecond problem."

The line worth saying verbatim, because it reframes the fix: "INP measures time to the next paint, not time to complete the work. So painting the pending state first, then yielding, then computing, can take an interaction from four hundred milliseconds to thirty without making anything actually faster."

And the depth signal: "I'd also check presentation delay specifically, because it's invisible in a JavaScript profiler and it's frequently the second-largest phase. That one is DOM size and style recalculation, and the fixes are virtualisation and content-visibility, not code changes."

Further reading

  • web.dev, "Interaction to Next Paint (INP)" and "Optimize INP", the primary reference including the three-phase model.
  • The web-vitals library documentation, particularly the attribution build.
  • Chrome Developers, "Optimize long tasks" and the scheduler.yield() documentation.
  • MDN and the CSS Containment specification for content-visibility and contain.
  • The React documentation on useTransition and concurrent rendering.