INP - Interaction to Next Paint
Deep dive into optimizing Interaction to Next Paint for responsive user interactions
Interaction to Next Paint (INP) measures the latency of all user interactions during a page visit, focusing on the longest interaction. It replaced First Input Delay (FID) as a Core Web Vital in March 2024.
Understanding INP
Score Thresholds
| Score | Rating | User Perception |
|---|---|---|
| ≤ 200ms | Good | Instant response |
| 200ms - 500ms | Needs Improvement | Noticeable lag |
| > 500ms | Poor | Feels broken |
What Counts as an Interaction?
INP measures:
- Clicks - Mouse clicks, taps on touch screens
- Key presses - Keyboard input
- Taps - Touch screen interactions
Scrolling and hovering are NOT counted as interactions for INP. These are handled by the browser's compositor thread.
INP Breakdown
| Component | Description | Common Causes |
|---|---|---|
| Input Delay | Time before handler runs | Long tasks blocking main thread |
| Processing Time | Handler execution time | Complex event handlers |
| Presentation Delay | Time to paint update | Large DOM updates, layout thrashing |
Common INP Issues in Next.js
1. Heavy Event Handlers
// ❌ Bad: Blocking the main thread
function handleClick() {
const result = expensiveCalculation(data);
setResults(result);
}
// ✅ Good: Defer heavy work
function handleClick() {
setIsLoading(true);
requestIdleCallback(() => {
const result = expensiveCalculation(data);
setResults(result);
setIsLoading(false);
});
}2. Unoptimized React Rendering
// ❌ Bad: Re-rendering entire list on filter
function ProductList({ products, filter }) {
const filtered = products.filter(p => p.category === filter);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
// ✅ Good: Memoized filtering and components
const ProductCard = memo(function ProductCard({ product }) {
return <div>{product.name}</div>;
});
function ProductList({ products, filter }) {
const filtered = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}3. Layout Thrashing
// ❌ Bad: Forced synchronous layout
function handleResize() {
elements.forEach(el => {
const height = el.offsetHeight;
el.style.width = height + 'px';
});
}
// ✅ Good: Batch reads and writes
function handleResize() {
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
el.style.width = heights[i] + 'px';
});
}4. Third-Party Script Blocking
// ❌ Bad: Blocking third-party script
<script src="https://analytics.example.com/heavy.js" />
// ✅ Good: Defer non-critical scripts
<Script
src="https://analytics.example.com/heavy.js"
strategy="lazyOnload"
/>
// Or load after interaction
<Script
src="https://chat-widget.com/widget.js"
strategy="afterInteractive"
/>Optimization Strategies
Use React Transitions
import { useTransition, useState } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setQuery(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: can be interrupted
setResults(filterResults(value));
});
}
return (
<>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <Results data={results} />}
</>
);
}Debounce Expensive Operations
import { useDeferredValue, useMemo } from 'react';
function SearchResults({ query }) {
// Defer the expensive value
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => expensiveFilter(items, deferredQuery),
[deferredQuery]
);
// Show stale data while computing
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{results.map(item => <Item key={item.id} {...item} />)}
</div>
);
}Web Workers for Heavy Computation
self.onmessage = (e) => {
const { items, query } = e.data;
const filtered = items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
self.postMessage(filtered);
};
// Search component
function Search({ items }) {
const [results, setResults] = useState(items);
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL('../workers/filter.worker.ts', import.meta.url)
);
workerRef.current.onmessage = (e) => setResults(e.data);
return () => workerRef.current?.terminate();
}, []);
function handleSearch(query) {
workerRef.current?.postMessage({ items, query });
}
return (/* ... */);
}Virtualize Long Lists
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}Diagnosing INP Issues
Chrome DevTools Performance
Record an interaction Start recording → Click/type → Stop recording
Find interaction marker Look for "Interaction" in the Interactions track
Analyze the three phases Check Input Delay, Processing, and Presentation
Identify long tasks Look for tasks > 50ms blocking the main thread
Programmatic Measurement
import { onINP, type INPMetric } from 'web-vitals';
export function measureINP() {
onINP((metric: INPMetric) => {
console.log('INP:', metric.value, 'ms');
console.log('Attribution:', metric.attribution);
// Get detailed breakdown
const { inputDelay, processingDuration, presentationDelay } =
metric.attribution;
sendToAnalytics({
name: 'INP',
value: metric.value,
inputDelay,
processingDuration,
presentationDelay,
interactionTarget: metric.attribution.interactionTarget,
});
});
}React-Specific Optimizations
Use React Compiler (React 19+)
module.exports = {
experimental: {
reactCompiler: true, // Automatic memoization
},
};Optimize State Updates
// ❌ Bad: Multiple state updates
function handleSubmit(data) {
setIsLoading(true);
setError(null);
setData(data);
}
// ✅ Good: Batched updates with reducer
const [state, dispatch] = useReducer(formReducer, initialState);
function handleSubmit(data) {
dispatch({ type: 'SUBMIT', payload: data });
}Avoid Inline Functions in Render
// ❌ Bad: New function every render
{items.map(item => (
<Button onClick={() => handleClick(item.id)}>
{item.name}
</Button>
))}
// ✅ Good: Stable reference
const handleItemClick = useCallback((id) => {
// handler logic
}, []);
{items.map(item => (
<ItemButton
key={item.id}
item={item}
onClick={handleItemClick}
/>
))}INP Optimization Checklist
Event Handlers
Keep handlers under 50ms Long tasks block the main thread and delay responses.
Use startTransition for non-urgent updates
Mark state updates as non-urgent so React can prioritize interactions.
Debounce rapid-fire events Prevent excessive processing on scroll, resize, or input events.
Move heavy work to Web Workers Offload computations from the main thread entirely.
Show immediate visual feedback Update UI instantly, even if data is still processing.
React Performance
Memoize expensive computations
Use useMemo to avoid recalculating on every render.
Use memo() for pure components
Prevent unnecessary re-renders of child components.
Virtualize long lists
Only render visible items using @tanstack/react-virtual.
Avoid inline function definitions
Create stable function references with useCallback.
Enable React Compiler if available Automatic memoization in React 19+.
Third-Party Scripts
Audit all third-party scripts Know exactly what's running on your page.
Use strategy="lazyOnload" for non-critical
Load analytics and widgets after page is interactive.
Consider removing heavy scripts Some scripts aren't worth their performance cost.
Use Partytown for moving scripts to workers Run third-party scripts off the main thread.
DOM & Layout
Batch DOM reads and writes Avoid layout thrashing by grouping similar operations.
Use CSS containment Isolate layout calculations to specific sections.
Avoid forced synchronous layouts Don't read layout properties immediately after writes.
Keep DOM size under 1500 nodes Smaller DOM means faster updates.
Real-World Impact
| Optimization | INP Improvement |
|---|---|
| React Transitions | -50ms to -200ms |
| List virtualization | -100ms to -500ms |
| Web Workers | -100ms to -300ms |
| Script deferral | -50ms to -150ms |
| Component memoization | -30ms to -100ms |