Fixing INP in React Apps That Already Pass LCP
How to debug and reduce Interaction to Next Paint in React apps where LCP is already green, covering event timing, render batching, and third-party scripts.

Your Lighthouse score for LCP hits green every time. The server is fast, images are optimized, and you’ve even got streaming SSR with Suspense working. But when you actually click around—typing in a search box, opening a dropdown, tapping a filter button—the page feels sluggish. The metric that catches this is Interaction to Next Paint (INP). Unlike LCP, which measures a single load event, INP samples the worst interaction delay across the entire session. A fast paint on first load doesn't guarantee the main thread stays free when the user starts working.
Why INP Fails When LCP Scores Well
INP measures responsiveness throughout the lifetime of the page. LCP is a load-phase metric; once the largest contentful element renders, the clock stops. INP keeps ticking. Every click, keypress, or tap is fair game, and the longest interaction—excluding flicks and scrolls—becomes the score. A team that optimised LCP by moving critical work to the server, adding fetchpriority=high to the hero image, and preloading fonts can still see an INP of 400 ms because those improvements do nothing for client-side interaction cost.
The common causes fall into three buckets:
- Long event handlers. A
onKeyUphandler that runs an expensive filter across a thousand items synchronously will block the main thread. - Forced reflows. Reading
offsetToporgetBoundingClientRect()inside a handler that also mutates styles forces the browser to recalculate layout before the next frame. - Large React render trees triggered by input. A state update at the root of a deep component tree can cascade re-renders through dozens of components that don't need to change.
The disconnect is straightforward: LCP is a server-and-network problem, while INP is primarily a main-thread scheduling problem. You can have the fastest server in the world and still fail INP because a chat widget executes a 150 ms task on every click.
Profiling Interactions with Chrome DevTools and web-vitals
Start with the Performance panel. Open DevTools, go to the Performance tab, and select the Web Vitals preset. This preset records with a low sampling rate focused on user interactions rather than loading. Click, type, or tap in your app, stop the recording, and look for Long Tasks—tasks that exceed 50 ms—in the main thread flame chart. Each long task is a suspect.
The web-vitals library now includes INP attribution. If you're using version 3 or later, the onINP callback receives a Metric object with an attribution property that tells you the target element, the event type, and the phases (input delay, processing time, presentation delay).
import {onINP} from 'web-vitals/attribution';
onINP(({value, attribution}) => {
console.log('INP:', value);
if (attribution) {
console.log('Target:', attribution.eventTarget);
console.log('Event type:', attribution.eventType);
console.log('Processing time:', attribution.inputDelay, attribution.processingDuration, attribution.presentationDelay);
}
});DevTools also exposes the Interactions section in the Performance panel after recording. Clicking an interaction entry shows three bars: Input delay (time from user action to the first event handler starting), Processing duration (time spent in the handler and any resulting renders), and Presentation delay (time until the next paint). Any bar over 50 ms is worth investigating.
Identifying Expensive Event Handlers and Re-renders
Once you know which interaction is slow, open React DevTools and switch to the Profiler tab. Start a recording, perform the same interaction, then stop. The flame chart shows every component that re-rendered and how long it took. Look for components that re-render but produce no visual diff—they're wasting frames.
useMemo and useCallback are often applied incorrectly. If a parent component re-renders and passes a new reference to a child, the child will re-render even if its props are "stabilised" with useMemo, because the parent's re-render creates a new object every time. The fix is either React.memo on the child or restructuring the tree so the expensive subtree doesn't re-render when its parent changes state that only affects a sibling.
For synthetic events like onChange on a text input, consider whether you need to update state on every keystroke. If you're filtering a list, debounce the handler by 150–300 ms. If you only need the value on form submit, use an uncontrolled input with a ref:
function SearchBox({onSearch}) {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
onSearch(inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} type="text" defaultValue="" />
<button type="submit">Search</button>
</form>
);
}This avoids re-rendering on every keystroke entirely. The trade-off is that you lose the ability to show live validation or a character count. When you genuinely need live feedback, throttle the handler to once per animation frame.
Batching and Deferring State Updates
React 18 batches state updates inside event handlers, effects, and lifecycle methods by default. That means two consecutive setState calls in a click handler will result in a single re-render. But batching breaks in some callbacks: setTimeout, Promise.then, and native event listeners registered with addEventListener may each flush synchronously.
To handle this explicitly, wrap non-urgent updates in startTransiton:
import {startTransition, useState} from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // urgent: show typed text
startTransiton(() => {
seResults(fetchResults(value)); // non-uregent: filter list
});
};
return (
<div>
<input value={query} onChange={handleChange} />
<ResultsList items={results} />
</div>
);
}The difference between setQuery and the transition-wrapped update is that React will prioritise the urgent render (the input showing the typed character) and interrupt the non-urgent one if the user types again. Without startTransaction, both updates are equally urgent, and a slow ResultsList will block the next keystroke.
For heavy computations that have nothing to do with DOM—parsing a large JSON blob, computing distances, running a regex over a long string—move the work to a Web Worker:
// worker.js
self.onmessage = (e) => {
const {data} = e;
const result = expensiveComputation(data);
self.postMessage(result);
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage(largeDataset);
worker.onmessage = (e) => {
setComputedResult(e.data);
};This keeps the main thread free to respond to user input. The primary cost becomes the postMessage serialisation, which is usually negligible compared to the computation itself.
Third-Party Scripts: The Silent IN P Killer
Third-party scripts are often the largest contributors to INP regressions in production. A chat widget that attaches a click listener to track user behaviour, an A/B testing script that rewrites the DOM on interaction, or annalytics snippet that serialises the page state on every tap—each adds a long task to the interaction path.
The Third-Party panel in DevTools (accessible from the top bar next to "Performance" or via the command menu) breaks down how much main-thread time each third-party origin consumes during interactions. Filter by the "Long Tasks" column to see which scripts are the worst offenders.
| Script type | Typical interaction cost | Mitigation strategy |
|---|---|---|
| Chat widget | 80–250 ms per click | Lazy-load on user idle, defer init |
| Analytics | 30–100 ms per event | Batch events, send via sendBeacon |
| A/B testing | 100–400 ms on first interaction | Load after requestIdleCallback |
| Ad network | 200+ ms | Restrict to non-interaction paths |
For any third-party script, apply these rules:
- Load it lazily. Use
asyncordeferfor<script>tags that don't need to execute before user interaction. For widgets, import them inside an event handler, not at module scope. - Use
fetchpriority="low"on resources that are not essential for the first interaction. - Defer initialisation until the user is idle with
requestIdleCallbackorsetTimeoutwith a 2–5 second delay.
If a script cannot be deferred because it's required for core functionality, isolate it in an iframe. Chrome's document.createElement('iframe') with an empty src and the script loaded inside the iframe creates a separate main thread context—the iframe's long tasks won't block your page's interactions. The trade-off is additional memory and more complex messaging.
Layout Thrashing and Forced Reflows
Reading a layout property like offsetTop, scrollHeight, or getBoundingClientRect() inside an event handler that has already mutated the DOM forces the browser to synchronously compute layout before returning the value. This is a forced reflow, and it can easily add 50–100 ms to an interaction, repeated on every handler call.
Batch your reads and writes. The pattern is:
function handleScroll(e) {
// Bad: interleaved reads and writes
const top = element.offsetTop;
element.style.top = `${top + 10}px`;
const height = wrapper.scrollHeight;
wrapper.style.height = `${height - 20}px`;
}
// Better: read first, then write
function handleScrollBetter(e) {
const top = element.offsetTop;
const height = wrapper.scrollHeight;
requestAnimationFrame(() => {
element.style.top = `${top + 10}px`;
wrapper.style.height = `${height - 20}px`;
});
}For complex sequences, use a library like fastdom that queues reads and writes and flushes them in the correct order per frame. Alternatively, use getComputedStyle and CSS.supports to query layout without triggering a reflow—these read from the computed style map, not the layout tree.
Also audit CSS transitions and animations that fire on interaction. A transition on transform or opacity is cheap (composited on the GPU), but a transition on width, height, or top triggers layout on every frame. If the user action deploys a CSS class that animates a layout property, you're paying for a forced reflow on every interaction.
Monitoring INP in Production with Real User Metrics
Debugging with DevTools tells you what happens on your machine, but INP varies wildly with device capability, network conditions, and the specific sequence of user actions. You need real-user monitoring (RUM) to see the distribution.
The web-vitals library is the standard way to collect INP:
import {onINP} from 'web-vitals';
onINP(({name, value, rating}) => {
// Send to nalytics
navigator.sendBeacon('/nalytics', JSON.stringify({name, value, rating}));
});Aggregate the data by p75 and p95. An INP of 150 ms at p75 but 600 ms at p95 suggests a specific slow interaction type (e.g., a modal open on a slow device) that gets lost in the median. Break down by:
- Page — Which routes are slowest? The product listing page or the checkout?
- Device category — Mobile, tablet, desktop.
- Interaction type — Click, keypress, tap.
Set a budget: p75 under 200 ms, p95 under 350 ms. The Chrome INP threshold for "good" is 200 ms, and for "needs improvement" it's 500 ms. Configure a custom alert in your RUM tool that fires when a page's p75 exceeds 250 ms or when the p95 jumps more than 50 ms from the 7-day rolling average.
What you measure shapes what you fix. If the data shows that mobile users on 3G connections have an INP of 400 ms on a specific page, and the long task is a third-party script, you know exactly where to invest.
Key takeaways
- INP measures the worst interaction across the entire page session, not just load—optimising LCP does nothing for it.
- React DevTools Profiler and the
web-vitalsattribution API pinpoint which component or event handler is slow. - Use
startTransitonto separate urgent state updates from non-urgent ones, and move heavy computation to Web Workers. - Third-party scripts are a leading cause of INP regressions; profile them with the Third-Party panel and defer or lazy-load aggressively.
- Layout thrashing from interleaved DOM reads and writes adds forced reflows; batch reads with
requestAnimationFrameand avoid animating layout properties.
Frequently asked questions
- What is the difference between INP and FID?
- FID measures the time from the first user interaction to the main thread processing the event. INP measures the latency of every interaction during the session, including the presentation frame. INP replaced FID as a Core Web Vital because it captures overall responsiveness, not just first impression.
- Can using React.memo everywhere fix INP?
- No. React.memo prevents re-renders only when props don't change, but it adds a shallow comparison cost. Overusing it can actually increase overhead. Instead, focus on reducing the number of components that re-render on interaction by lifting state up or using context selectively.
- Should I use useDeferredValue or startTransition for INP?
- startTransition is for marking a state update as non-urgent inside an event handler. useDeferredValue is for deferring a value derived from urgent state. Both help INP by keeping the main thread free for the next interaction, but useDeferredValue is better when the slow part is a computed value (like a filtered list), not the update itself.


