Your users are typing into a search input and your UI freezes for 344ms. They notice. And now, Google notices too.
In March 2024, Google replaced First Input Delay (FID) with a new Core Web Vital called Interaction to Next Paint (INP) — and it changed the game. FID only measured the first interaction. INP measures the worst one. That means the janky filter component you've been ignoring? It's now dragging your entire site's SEO ranking down.
I spent time profiling a React app that filters through 1,900+ cat breeds (yes, really), and the results were eye-opening. Here's everything I learned about diagnosing and fixing INP — including a React 18 trick that made the problem disappear in three lines of code.
What INP Actually Measures (and Why FID Was Lying to You)
Here's the thing about FID: it only cared about your first interaction. Click a button when the page loads? FID measures the delay. But that search input your user hammers 30 times during a session? FID ignores it entirely.
INP flips this on its head. It watches every interaction — every click, tap, and keypress — and reports the worst one. More precisely, for sessions with 50+ interactions, it takes the 75th percentile of the slowest latencies. This eliminates one-off hiccups while still catching the patterns that make your app feel broken.
An "interaction" in INP terms is one of three things: a mouse click, a touchscreen tap, or a keypress. And the measurement covers the full lifecycle:
- Input delay — the gap between the user's action and your event handler actually running
- Processing time — how long your event callbacks take to complete
- Presentation delay — how long the browser takes to paint the next frame with visual feedback
Picture a user typing "B" into a search field that filters a list. INP measures from the moment that keydown event fires to the moment the filtered list is visually rendered on screen. Every millisecond in that window counts.
Think of INP as your app's worst moment, not its first impression. FID was a handshake. INP is the entire relationship.
"But I Don't Care About SEO"
Fair. But consider this: a poor INP score is a symptom. It means your app has interactions that feel sluggish to real users. Even if you're not optimizing for Google, INP is a free diagnostic tool — a canary in the coal mine for responsiveness issues you should probably fix anyway.
If you do care about SEO, this is non-negotiable. INP directly influences your search ranking.
How to Find Your Worst Interactions (Without Guessing)
If your site has production traffic and qualifies for Chrome's User Experience Report (CrUX), start with PageSpeed Insights. It'll give you a 28-day snapshot of real-user INP data. Done.
But let's say you need to diagnose things locally. Here's the setup I use:
What you need:
- Chrome
- Web Vitals extension — gives you a HUD overlay and logs every interaction's latency to the console
- React DevTools — enable "Highlight updates when components render" in the Profiler tab
With both running, interact with your app normally. Type into inputs. Click buttons. Scroll. The Web Vitals extension logs every interaction's INP delta time in the console, and React DevTools shows you exactly which components re-render.
This is where things get revealing.
The 344ms Bug: A Debugging Story
I built a simple app — a search input that filters a list of 1,900+ cat breeds. Here's the component:
export function CatsBreedsBrowserTemplate() {
const { data, isLoading } = useCatsData();
const [filterByBreed, setFilterByBreed] = useState('');
const searchResults = useMemo(() => {
const catsData = data ?? [];
if (!filterByBreed || !catsData.length) return catsData;
return catsData.filter((cat) => cat.breed.includes(filterByBreed));
}, [data, filterByBreed]);
const renderCatsSearchResult = useCallback(() => {
if (isLoading) return <CircularProgress />;
if (!searchResults.length) return <p>No cats found :(</p>;
return <CatsList cats={searchResults} />;
}, [isLoading, searchResults]);
return (
<div>
<SearchBar
label="Filter by cat's breed"
onChange={(e) => setFilterByBreed(e.target.value)}
/>
{renderCatsSearchResult()}
</div>
);
}
Looks reasonable, right? useMemo for filtering, useCallback for the render function. Textbook React optimization.
I typed "B" into the input and cleared it. The UI froze. The console showed an INP of 344ms. That's a failing score.
Two things were happening:
setFilterByBreedfires on every keystroke, which triggers a re-render- Every re-render forces
CatsListto reconcile 1,900+ items synchronously
The memoization wasn't saving us — the state change itself was the trigger, and React was dutifully re-rendering the entire tree every time.
Fix #1: Debounce the Input (The Obvious One)
The first instinct is to stop hammering the state on every keystroke. Instead of updating filterByBreed immediately, debounce it:
const DEFAULT_DEBOUNCE_DELAY = 500;
export const debounce = <T>(
callback: (value: T) => void,
delay = DEFAULT_DEBOUNCE_DELAY
) => {
let timer: number;
return (value: T) => {
clearTimeout(timer);
timer = setTimeout(() => callback(value), delay);
};
};
Then update the SearchBar component:
// ❌ Before: fires on every keystroke
function SearchBar({ onChange, ...rest }: SearchBarProps) {
return <TextField onChange={onChange} {...rest} />;
}
// ✅ After: waits for the user to stop typing
function SearchBar({ onChange, ...rest }: SearchBarProps) {
const debouncedOnChange = useMemo(
() => onChange && debounce(onChange),
[onChange]
);
return <TextField onChange={debouncedOnChange} {...rest} />;
}
This helps. The expensive CatsList re-render doesn't fire until the user pauses. INP improves because we're no longer blocking the main thread on every keystroke.
But we still have a problem: when the debounce does fire, we're rendering 1,900 DOM nodes synchronously. The freeze is shorter but it's still there.
Fix #2: Stop Rendering 1,900 Items (The Structural One)
Two options here, and both solve the same underlying problem — too many DOM nodes:
Virtualization: Only render the items visible in the viewport (plus a small buffer). Libraries like react-window or @tanstack/virtual handle this. The DOM stays lightweight regardless of list size.
Pagination / Infinite scroll: Combine IntersectionObserver with incremental loading. Start with a small batch, append more as the user scrolls.
Either approach keeps the initial render cheap. The "freeze" disappears because the browser never has to layout thousands of elements at once.
Aside: Virtualization is usually the better choice for search/filter UIs because the list size changes unpredictably. Pagination works better for chronological feeds where users scroll in one direction.
Fix #3: useTransition — The One That Changed My Mental Model
Here's where it gets interesting.
Everything I just described — debouncing, virtualization — those are techniques you manage. You're doing React's job for it. React 18 introduced a way to let React handle this natively.
The key insight: not all state updates are equally urgent.
When the user types into the search input, two things need to happen:
- The input value needs to update (urgent — the user needs to see their keystrokes)
- The filtered list needs to re-render (not urgent — it can wait a few frames)
Before React 18, both updates were treated identically. React processed them sequentially, blocking everything until completion. That's why the UI froze — JavaScript's run-to-completion model means nothing else can happen until that synchronous render finishes.
useTransition lets you tell React: "this update can wait."
// ❌ Before: every keystroke is urgent
onChange={(e) => setFilterByBreed(e.target.value)}
// ✅ After: filtering is marked as non-urgent
import { useTransition } from "react";
export function CatsBreedsBrowserTemplate() {
const [isPending, startTransition] = useTransition();
// ... everything else stays the same
return (
<div>
<SearchBar
label="Filter by cat's breed"
onChange={(e) =>
startTransition(() => {
setFilterByBreed(e.target.value);
})
}
/>
{renderCatsSearchResult()}
</div>
);
}
Three lines of code. The input now responds instantly, and the list updates when the browser has time.
Why does this work? React 18 changed how the update queue processes work. Instead of running an update to completion, React now schedules units of work, checks if the browser needs to do something higher-priority (like responding to user input), and yields control back if it does. The non-urgent update gets split into chunks and processed across multiple frames.
The mental model shift: Stop thinking about re-renders. Start thinking about the urgency of updates.
isPending even gives you a boolean to show a loading state while the transition is in progress — so you can give the user feedback that something is happening without freezing the entire UI.
Aside: useDeferredValue is another concurrent hook worth exploring. It lets you defer a value rather than a state update — useful when you don't control the state setter.
When Concurrency Isn't Enough
I'd be dishonest if I said useTransition solves everything. It doesn't.
React's concurrent renderer splits work into chunks. But if a single component is expensive to render — say, a complex SVG chart or a canvas operation — there's no way to split that unit of work further. The chunk itself is the bottleneck.
In those cases, you need to combine concurrent scheduling with the traditional techniques: virtualization, memoization, or restructuring your component tree so expensive renders are isolated.
The priority order I'd recommend:
- First, check if you're rendering too many DOM nodes (virtualize or paginate)
- Then, check if state updates are triggering unnecessary re-renders (memoize or restructure)
- Finally, use
useTransitionoruseDeferredValueto mark non-urgent updates - Profile again. Repeat.
The Trap of Premature Optimization
One more thing. Everything in this post is a tool, not a mandate.
Don't wrap every setState in startTransition. Don't virtualize a list of 20 items. Don't add useMemo to a function that runs in 0.1ms. Every optimization has a cost — more memory, more complexity, more code to maintain.
Monitor first. Find the interactions that actually score poorly on INP. Fix those. Leave the rest alone.
The Takeaway
Next time you touch a React component with a text input that filters a list, ask yourself one question: "Is this state update urgent?"
If the answer is no, wrap it in startTransition. It takes 10 seconds and it might be the difference between a smooth UI and a 344ms freeze that tanks your SEO.
INP isn't measuring your app's best moment. It's measuring your worst. Make sure your worst is still good enough.