+1 (781) 556-6029

Fixing INP in a React app: find the long task, not the component

A team shows us a Search Console warning for INP, and the first thing they want to talk about is React.memo. That is usually the wrong end of the problem. Interaction to Next Paint does not measure components; it measures a stretch of main-thread time between a user's input and the next frame the browser manages to paint. The component that re-rendered may be innocent. The analytics script that was parsing JSON when the click arrived may not be.

This is the diagnostic order we use. It is deliberately boring: attribute the interaction in the field, reproduce it in the lab, work out which of the three phases is actually long, and only then change code.

INP is three numbers, not one

Every interaction decomposes into:

  • Input delay — from the user's input until your event handler starts running. The main thread was busy with something else.
  • Processing duration — your event handlers running, including any synchronous React render they trigger.
  • Presentation delay — from the handlers finishing until the browser paints the next frame: style, layout, paint, compositing.

A 600ms INP with 500ms of input delay and a 600ms INP with 500ms of processing are different bugs with different fixes. Teams that skip this split usually spend a sprint memoizing components to fix a third-party script.

Note also that INP reports the page's worst interaction (roughly — the high percentile for pages with many interactions), not the average. One bad modal open can own the score for the whole session.

Step 1: attribute it in the field

Lab profiling tells you nothing until you know which interaction to profile. The web-vitals library's attribution build gives you, per INP event: interactionTarget (a selector for the element), inputDelay, processingDuration, presentationDelay, and longAnimationFrameEntries from the Long Animation Frames API.

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

onINP(({ value, attribution }) => {
  send('inp', {
    value,
    target: attribution.interactionTarget,
    type: attribution.interactionType,       // 'pointer' | 'keyboard'
    inputDelay: attribution.inputDelay,
    processing: attribution.processingDuration,
    presentation: attribution.presentationDelay,
    scripts: attribution.longAnimationFrameEntries
      ?.flatMap(f => f.scripts ?? [])
      .map(s => [s.invoker, Math.round(s.duration)]),
  });
}, { reportAllChanges: false });

Segment what comes back by route and by device class. The pattern we see most often is that INP is fine on desktop, fine on the dashboard, and terrible on one filter control on mid-range Android. That single row is your work order. longAnimationFrameEntries[].scripts[].invoker frequently names the offending script outright, which skips a whole afternoon of guessing.

Step 2: reproduce it in the lab

Open the page in Chrome DevTools, Performance panel, CPU throttling at 4x or 6x — not because users have 6x-slower machines than you, but because an unthrottled dev laptop hides exactly the long tasks you are hunting. Record, perform the attributed interaction, stop.

Read the Interactions track first. Each interaction appears as a bar you can expand into input delay, processing, and presentation. Line it up against the Main track to see what occupied the thread.

If you are on React 19.2 or later, also turn on the React performance tracks (Scheduler, Components). They tell you which update was scheduled at what priority and which component tree rendered inside the long task, which is the one thing a raw flame chart cannot show you. We wrote about those tracks separately; for INP work their value is distinguishing "React rendered for 180ms" from "React rendered for 12ms and then your chart library laid out for 180ms."

Step 3: fix the phase that is long

Input delay dominates

The thread was busy before your code ran. Causes, in rough order of frequency:

  • Third-party tags — tag managers, session replay, A/B test scripts — doing work on a timer. Move them off the critical path, load them with async/defer, or run them in a worker if the vendor supports it. Measure with the tag disabled before you argue with marketing about it; bring the number, not the opinion.
  • Hydration still running when the user clicks. The fix is less client JavaScript on that route, not faster hydration: Server Components for the static parts, a smaller client boundary, or deferring non-interactive widgets.
  • Your own long tasks — a big useEffect on mount, a synchronous JSON.parse of a cached payload, chart initialisation. Break them up and yield.

Yielding is cheap to add:

async function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) return scheduler.yield();
  return new Promise(r => setTimeout(r, 0));
}

for (const chunk of chunks) {
  processChunk(chunk);
  await yieldToMain();        // lets a queued click run between chunks
}

scheduler.yield() is better than setTimeout(0) because it keeps your continuation ahead of other queued tasks instead of at the back of the line.

Processing duration dominates

Now it is your handler, and often the render it triggers. In order:

  1. Split urgent from non-urgent updates. Typing into a filter should paint the keystroke immediately and update the 3,000-row table afterwards. useDeferredValue on the value passed to the expensive subtree, or startTransition around the non-urgent setState, gets you that without debouncing. Debouncing hides the latency by delaying feedback; a transition keeps the feedback and moves the cost.
  2. Virtualize long lists. No amount of memoization makes 3,000 rows cheap to reconcile and lay out. @tanstack/virtual or similar is the structural fix.
  3. Narrow the update. A context value recreated on every keystroke re-renders every consumer. Split the context, or move the fast-changing value into a store with useSyncExternalStore so only subscribers re-render. This is the point where profiling re-renders — find the re-render before you reach for memo — is the right next step, not the first one.
  4. Get synchronous I/O out of the handler. localStorage writes, getBoundingClientRect() in a loop, JSON.stringify of a large state object. Defer them past the paint.

The React Compiler helps here only where the cost was avoidable re-rendering. It does not make a genuinely expensive render cheap, and it does not touch input delay or presentation delay at all.

Presentation delay dominates

The handlers finished quickly and the browser still could not paint. Look for:

  • A very large DOM, so style recalculation and layout are expensive for any change. content-visibility: auto on offscreen sections helps; fewer nodes helps more.
  • Layout thrash — reading geometry after writing to the DOM, repeatedly, inside the same frame.
  • Animating top/left/width instead of transform/opacity, so every frame relayouts.
  • Opening a modal or popover that mounts a large tree at once. Mount the shell, stream the contents.

Step 4: re-measure before you keep the fix

Confirm in the lab trace that the specific phase shrank for the specific interaction. Then wait for the field: CrUX reports p75 over a 28-day window, so a real improvement shows up gradually and a traffic-mix change can move the number for reasons unrelated to your work. Keep the lab trace from before and after — that is the evidence that the change did what you said it did.

Add a guard if the interaction matters: a Playwright script that performs it under CPU throttling and asserts on the measured duration will catch the regression the week it merges rather than the month after.

What we usually find

Across the reviews we run, INP problems split roughly into: one third third-party scripts, one third an unvirtualized list or table, one third a context or store update with a far wider blast radius than anyone intended. Only the last of those is the problem most teams start with.

If your attribution data already names the interaction and the script, you probably do not need us — the fix list above is short and well documented. Where an outside pass earns its cost is when the trace is ambiguous, when the expensive update is load-bearing for the product, or when the fix means changing how state is shared across a large application. That is architecture work, and it is worth measuring carefully before anyone starts it.