DEV Community

Cover image for Fixing a bad INP in React? Find the slow phase before you optimize anything
Redlio Labs
Redlio Labs

Posted on AI-assisted

Fixing a bad INP in React? Find the slow phase before you optimize anything

Interaction to Next Paint (INP) has been a Core Web Vital since March 2024, when it replaced First Input Delay. It's also the one teams find hardest to fix, because the usual advice ("reduce JavaScript", "use memo") is aimed at the wrong thing half the time.

INP isn't one number you can attack directly. It's the latency of a real user's slowest interactions, and each interaction's latency is made of three phases that have completely different causes. Fix the wrong phase and the score doesn't move.

So the order matters: find the interaction, find the slow phase, then pick the fix. Here's how we do that in React apps.

What INP actually measures

From web.dev's INP guide, an interaction's latency has three parts:

  1. Input delay: from the user's tap or click until your event handlers start running. Long if the main thread is busy with something else at that moment.
  2. Processing duration: how long your event handlers take to run.
  3. Presentation delay: from the end of your handlers until the browser paints the next frame. Long if the update triggers heavy rendering, layout or style work.

The thresholds, at the 75th percentile of page loads:

INP Rating
200 ms or less Good
200–500 ms Needs improvement
Over 500 ms Poor

A page with a 520 ms INP might be 90 ms of input delay, 310 ms of processing and 120 ms of presentation. Memoizing components would barely touch that. Moving work out of the click handler would.

Step 1: Measure in the field, with attribution

Lab tools can tell you an interaction can be slow. Only field data tells you which interactions real users hit, on which devices. The web-vitals library's attribution build gives you the phase breakdown and the element that was interacted with:

// vitals.js
import { onINP } from "web-vitals/attribution";

onINP(({ value, rating, attribution }) => {
  const body = JSON.stringify({
    metric: "INP",
    value: Math.round(value),
    rating,
    target: attribution.interactionTarget,   // CSS selector of the element
    type: attribution.interactionType,       // "pointer" or "keyboard"
    inputDelay: Math.round(attribution.inputDelay),
    processing: Math.round(attribution.processingDuration),
    presentation: Math.round(attribution.presentationDelay),
    loadState: attribution.loadState,        // was the page still loading?
    page: location.pathname,
  });

  // sendBeacon survives the page being closed
  navigator.sendBeacon("/api/vitals", body);
});
Enter fullscreen mode Exit fullscreen mode

Store those rows anywhere you can query. After a few days, group by target and look at the worst ones:

SELECT target,
       COUNT(*)                                            AS samples,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value)        AS p75_inp,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY input_delay)  AS p75_input,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY processing)   AS p75_processing,
       PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY presentation) AS p75_presentation
FROM   vitals
WHERE  metric = 'INP' AND created_at > now() - interval '7 days'
GROUP  BY target
ORDER  BY p75_inp DESC
LIMIT  20;
Enter fullscreen mode Exit fullscreen mode

The top of that list is your worklist, and the largest of the three phase columns tells you which section below to read.

Step 2a: Input delay is high → something else owns the main thread

The user clicked, but your handler had to wait. Common causes in React apps:

  • Hydration. On server-rendered pages, early clicks land while React is still hydrating. If loadState in your data is mostly "dom-interactive" or "dom-content-loaded", this is it.
  • Third-party scripts. Chat widgets, tag managers, A/B testing and session recorders that run long tasks on load or on a timer.
  • Your own timers and polling that do heavy work on the main thread.

What helps:

  • Load third-party scripts after the page is interactive, or move them off the main thread where the vendor supports it.
  • Split large client bundles so less JavaScript runs during hydration. In Next.js App Router or other React Server Components setups, keep components on the server unless they need interactivity.
  • Break up your own long tasks (see the yield helper in the next section). web.dev defines a long task as anything over 50 ms. Every one of them is a window where a click has to wait.

Step 2b: Processing duration is high → your handler does too much

This is the most common case we see, and the easiest to fix. The handler does everything synchronously: updates the filter state, recalculates a big list, writes to localStorage, fires analytics, all before the browser is allowed to paint.

The principle: do the minimum needed for visual feedback, paint, then do the rest.

In React, that usually means marking the expensive update as non-urgent with startTransition:

import { useState, useTransition } from "react";

function ProductFilters({ products }) {
  const [query, setQuery] = useState("");
  const [visible, setVisible] = useState(products);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    const next = e.target.value;
    setQuery(next);                 // urgent: the input must update now

    startTransition(() => {         // non-urgent: React can interrupt this
      setVisible(filterProducts(products, next));
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending && <span className="spinner" aria-live="polite">Updating…</span>}
      <ProductList items={visible} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

For work that isn't a React state update (analytics, storage, logging), yield to the main thread so the browser can paint first. scheduler.yield() is built for this. It's supported in Chromium-based browsers and Firefox, but not Safari yet, so keep a fallback:

// yield.js
export function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}
Enter fullscreen mode Exit fullscreen mode
async function onAddToCart(item) {
  setCart((c) => [...c, item]);   // visible feedback first

  await yieldToMain();            // let the browser paint

  saveCartToStorage();            // then the slower, invisible work
  track("add_to_cart", { id: item.id });
}
Enter fullscreen mode Exit fullscreen mode

Two more things that hide in handlers:

  • Large synchronous JSON.stringify / localStorage.setItem calls. Fine for small objects, slow for a whole cart or form state on every keystroke. Debounce them.
  • Context updates that re-render half the app. If a click updates a context value that dozens of components read, the handler's cost includes every one of those renders. Split the context or move the state down.

Step 2c: Presentation delay is high → the update is expensive to render

Your handler was quick, but the result is a lot of DOM to update, lay out and paint.

  • Long lists. Rendering 2,000 table rows after a filter is slow no matter how fast the filter is. Virtualize with something like TanStack Virtual or react-window, so only visible rows exist in the DOM.
  • Huge DOM in general. Big pages make every style and layout recalculation slower. For long content below the fold, the CSS property content-visibility: auto lets the browser skip rendering work for off-screen sections.
  • Layout thrashing. Reading layout (offsetHeight, getBoundingClientRect) right after writing styles forces the browser to lay out synchronously. Batch reads before writes.
  • Expensive CSS on elements that change often, such as large blurred shadows or filters on animated elements.

Step 3: Reproduce in the lab, then confirm in the field

Once you know the interaction and the phase, reproduce it in Chrome DevTools:

  1. Open the Performance panel and turn on CPU throttling (4x or 6x) to approximate a mid-range phone.
  2. Record while performing the exact interaction from your field data.
  3. Look at the Interactions track. The interaction's bar is split into input delay, processing and presentation, and the main-thread flame chart below shows what ran in each.

Fix, re-record, compare. Then wait for the field data to catch up. Your own /api/vitals data will show the change within days. Google's Chrome UX Report uses a rolling 28-day window, so Search Console takes about a month to reflect it.

A quick checklist

  • [ ] web-vitals/attribution is reporting INP with phase breakdown and target
  • [ ] You have the top 10 worst interactions by p75, from real users
  • [ ] For each one, you know which of the three phases dominates
  • [ ] Input delay → third parties deferred, hydration work reduced, long tasks split
  • [ ] Processing → visual update first, startTransition or yieldToMain() for the rest
  • [ ] Presentation → long lists virtualized, DOM size checked, no layout thrashing
  • [ ] Fix verified in DevTools with CPU throttling, then in field data

INP rewards a specific kind of discipline: give the user visual feedback immediately, and push everything else just after the paint. Once a team has that habit, most new features arrive with a good INP by default.

We set performance targets like these before a page is designed, as part of how we approach custom web development. It's much cheaper than finding them in Search Console six months after launch.

Your turn: which phase was the culprit in your worst INP interaction? In our experience it's processing more often than not, but we'd like to hear about the odd ones, especially third-party scripts that only misbehave on certain pages.


From the team at Redlio Labs, where we build and run web applications.

Top comments (0)