All insights
Software development
11 min read

Fixing INP in a real React app: a teardown of the slow interactions we found and what we changed

A walkthrough of an INP rescue on a production B2B dashboard. The profiling we used, the four interactions that were dragging the 75th percentile into the red, and the React 18 and architectural fixes — useDeferredValue, transitions, event delegation, virtualisation, hydration boundaries — that moved the app from failing to passing.

Published
Published 22 May 2026

Key takeaways

  • INP became a Core Web Vital in March 2024, replacing FID — and it is measured at the 75th percentile of all click, tap and keyboard interactions on the page, not a single first input (web.dev).
  • Globally, 77% of mobile pages have "good" INP in 2025, up from 55% in 2022 — but mobile secondary pages sit at 69%, an 11-point gap behind home pages (Web Almanac 2025).
  • INP has three phases — input delay, processing duration, presentation delay — and the worst interactions in a typical React dashboard hit on at least two (web.dev).
  • The cheapest wins are usually structural: defer non-essential work out of event handlers, mark wide-scope state updates as transitions, and virtualise long lists. The expensive wins are architectural: move work off the critical path entirely.
  • useTransition and useDeferredValue solve different problems. Transitions are interruptible state updates you own; deferred values are for inputs whose set function you do not control (React docs).

TL;DR

The app was a B2B analytics dashboard for a customer with a few hundred concurrent users. INP at the 75th percentile sat around 540ms on mid-range mobile hardware — solid red on Google's scale. The 75th percentile is the bar that matters because it represents the slowest interaction a typical session will experience, not an average. Four interactions accounted for almost all of the long tail: a filter sidebar that re-rendered the entire results grid on every checkbox click, a search input that ran a synchronous fuzzy match against the cached rows, a date-range picker that re-fetched and reflowed sibling charts in the same task, and a row-action menu that re-mounted on every open. After three weeks of work we landed at roughly 180ms p75. None of the fixes were exotic. The mistake was that the original code treated React as if it batched everything for free and the main thread had infinite headroom.

What we were looking at

The app was a Next.js 14 dashboard rendered as a mostly-client SPA after the initial server response, talking to a tRPC backend. Around 12,000 active rows in the primary view at any time, plus four supporting charts that updated when filters changed. The customer's own bug reports were not "the page is slow" — they were "the search box feels broken" and "I have to click the filter twice." Both are classic INP symptoms. The user clicks, nothing visibly changes for half a second, they assume it didn't take, they click again. INP captures exactly this latency: web.dev defines it as the time from "the start of the interaction to the moment the next frame is fully presented" (web.dev).

The metric also has a sharper edge than people remember. INP is not the average across a session — it is roughly the worst interaction, with outliers trimmed. The spec is the longest interaction observed for short sessions, transitioning to a near-maximum estimator for sessions with many interactions. So one slow filter click in a thirty-minute analyst session is enough to define the page's INP for that user. You cannot average your way out of one bad interaction.

How we measured

Three tools, in this order:

Real-user monitoring first. We had the web-vitals library shipping INP samples to our analytics pipeline. That told us which routes had the problem and which interactions were responsible, via the event.target and event.type of the slowest interaction in each session. Without this we would have been guessing about which of dozens of interactive elements mattered. Field data is the only data Google's ranking algorithm actually uses; lab data is for debugging.

Chrome DevTools Performance panel, second. Once we knew which interaction to reproduce, the Performance panel's interaction track gave the breakdown — input delay, the script tasks that ran during processing, and the rendering and paint work at the end. The three-phase model from web.dev maps directly onto what you see there: "input delay (before event callbacks start), processing duration (event callbacks running), and presentation delay (time until the next frame displays visual results)" (web.dev).

React Profiler last, and sparingly. It is good for seeing which components re-rendered and roughly why, but it overstates render cost compared to the actual numbers in the Performance panel and it does not show non-React work. Use it to answer "did this component re-render at all," not "how slow was the interaction."

A note on PageSpeed Insights: it is a useful sanity check because it pulls 28-day field data from CrUX, but you cannot iterate against it. The data is too coarse and too lagged. Use it to verify a fix held a month later, not to drive a debugging loop.

The four interactions that were costing us

1. The filter sidebar that re-rendered the whole grid

The original implementation kept a single filters object in a React Context at the top of the page. Every checkbox toggle called setFilters with a new object, which broke memoisation on roughly every component subscribed to that context — including the 12,000-row grid which then re-ran its filtering logic synchronously inside the event handler. Processing time per click: 380-520ms on mid-range hardware.

The fix had two parts. First, we split the context so the grid subscribed to a derived visibleRows value rather than the raw filter state, and we computed visibleRows with useMemo keyed on the filter object. Cheap, obvious in retrospect. Second — and this was the bigger win — we wrapped the filter update in startTransition:

const [isPending, startTransition] = useTransition() function onFilterChange(next: FilterState) { startTransition(() => setFilters(next)) }

This does two things. It marks the resulting re-render as interruptible, and it lets us show a subtle isPending indicator on the grid without blocking the checkbox itself from updating immediately. The React docs are explicit on the value here: "while a Transition is in progress, your UI stays responsive...if the user clicks a tab but then changes their mind and clicks another tab, the second click will be immediately handled without waiting for the first update to finish" (React docs).

The checkbox now updates in the next frame regardless of what the grid is doing. The grid catches up when it can.

2. The search input running synchronous fuzzy matching

Same shape, different cause. The search input stored its value in component state, and an effect re-ran the fuzzy matcher whenever the value changed. Because the input was controlled, every keystroke triggered an urgent state update, which kicked the fuzzy match to the next render, which ran on the main thread before the next paint.

useTransition does not help here directly — you cannot wrap a controlled input's setState in a transition without delaying the input feedback itself. This is exactly the case useDeferredValue exists for. The React docs say it bluntly: "if you want to start a Transition in response to some prop or a custom Hook value, try useDeferredValue instead" (React docs).

We changed the search component to read a deferred copy of the query for the expensive match:

const [query, setQuery] = useState("") const deferredQuery = useDeferredValue(query) const results = useMemo( () => fuzzyMatch(rows, deferredQuery), [rows, deferredQuery] )

The input itself stays urgent. The results list updates on the next available frame after the keystroke. This alone took the search interaction p75 from ~410ms to under 100ms.

3. The date-range picker that did too much in one task

When a user picked a new date range, three things happened in the same event handler: a tRPC call to refetch the primary dataset, a recomputation of four supporting chart aggregations from the new dataset once it returned, and a re-render of every chart with a smooth transition animation. The fetch was already async, but the post-fetch work — aggregation, layout, transition setup — all landed in a single long task once the promise resolved.

The fix here was unglamorous: break it up. We moved the chart aggregations behind a small scheduler-style helper that yielded to the browser between charts:

async function recomputeCharts(dataset: Dataset) { for (const chart of chartIds) { await yieldToMain() aggregateAndSet(chart, dataset) } } const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))

setTimeout(0) is good enough on every browser our customers use. If you have access to scheduler.yield() and want the better semantics, use it where supported, but the simple version is fine for most cases. The web.dev guidance is generic for a reason: "break up the work in event callbacks into separate tasks. This prevents the collective work from becoming a long task" (web.dev).

This was the single largest p75 improvement, because the date-range picker was the most pathological interaction we had.

4. The row-action menu that re-mounted

The row-action popover was a portal that mounted into the DOM the first time it was opened on each row, then unmounted on close. Opening it ran the full popover initialisation — layout measurement, focus management, ARIA wiring — inside the click handler. p75 click-to-open latency was around 220ms.

We swapped to a single page-level popover instance with a portal target that re-anchored to the clicked row. The popover stays mounted; only its position and contents change. Click-to-open dropped under 60ms. This is the kind of fix the React Profiler is useless for, because the cost is not in render time — it is in DOM measurement and event setup. The Performance panel is the only tool that tells you the truth here.

The architectural fixes that mattered alongside

Two changes were not about specific interactions, but about reducing the baseline work on the main thread.

Virtualising the grid. The 12,000-row grid was rendering every row to the DOM, hidden via overflow: hidden and CSS scrolling. Even when the grid was not being re-rendered, it was a 12,000-node subtree that the browser had to keep in style and layout. Switching to a windowed virtualiser brought rendered rows down to roughly 40-60 at any moment. INP improved on every interaction on that page, not just the grid-touching ones, because the main thread had less standing work to compete with.

Tightening hydration boundaries. The route was a "use client" page from its root, which meant the entire interactive tree hydrated on first load and contributed to input delay during the post-load window. We pushed "use client" down to the actual interactive components — the filter sidebar, the search input, the chart controls — and left the static shell and the initial chart rendering on the server. The relevant phrase from web.dev: "script evaluation...can introduce long tasks on the main thread, which will delay the browser from responding to other user interactions" (web.dev). Less script during the hydration window meant less input delay during the first ten seconds of a session, which is when most users do their first filter click.

Where the numbers landed

Before: 75th percentile INP around 540ms across the affected dashboard routes, with the filter sidebar and date-range picker contributing most of the long tail. After: roughly 180ms p75, holding through a month of field data. The site moved from "poor" to "good" on Google's INP scale (web.dev).

For context: the 2025 Web Almanac put global mobile "good INP" at 77%, with mobile home pages at 80% and secondary pages at 69% — meaning analytics-heavy interior pages like this one tend to be where the metric goes off (Web Almanac 2025). Landing under 200ms on a dense interior view is the harder case, and the one that matters for B2B software where users live inside one interior page for entire sessions.

What we would do differently

Two things, in order of regret.

We would have added the web-vitals RUM script in week one, not week three. Without field data we spent the first sprint optimising things that were already fine because they were obvious in the Performance panel. The field data redirected us to the actual culprits within a day of being shipped.

We would have written the original components against React's concurrency model from the start. Most of the fixes were not refactors — they were single-line changes that wrapped state updates in startTransition or replaced a state read with useDeferredValue. If the team had been treating "is this state update urgent or interruptible" as a routine question during code review, the app would not have ended up in the red in the first place. The mental model is simple: anything the user is directly typing or clicking is urgent; anything downstream of that — filtering, sorting, recomputing, redrawing — is almost always a transition.

INP is the most-failed Core Web Vital in 2026 not because it is unfixable but because the patterns above are not yet defaults in how most React apps are written. They are not exotic. They are routine engineering hygiene once you have seen the bill on the other side.


DevLume helps small-to-mid B2B engineering teams diagnose and fix production performance problems like this one — typically in a focused two-to-four-week engagement with the team rather than instead of them. If you have a dashboard that feels slow and you want to know whether it is two days of work or two months, get in touch.

Start a conversation

Want to talk about this?

We are happy to discuss the ideas in this note — or where you see things differently.