Interaction to Next Paint (INP)
Optimize responsiveness with the new Core Web Vital metric replacing FID
Interaction to Next Paint (INP)
INP is Google's newest Core Web Vital metric, replacing FID (First Input Delay) in March 2024. It measures overall responsiveness throughout the entire page lifecycle.
What is INP?
Interaction to Next Paint measures the latency of ALL user interactions (clicks, taps, keyboard) during the entire visit.
INP vs FID
FID (Old):
- Only measures FIRST interaction
- Misses ongoing responsiveness issues
- Replaced by INP in 2024
INP (New):
- Measures ALL interactions
- Better representation of user experience
- Now a Core Web VitalINP Scoring
Good: < 200ms
Needs Improvement: 200-500ms
Poor: > 500ms
Target: All interactions should respond within 200msWhat INP Measures
User clicks button
↓
[Input Delay] ← Time until browser starts processing
↓
[Processing Time] ← JavaScript execution
↓
[Presentation Delay] ← Rendering updates
↓
Visual feedback appears
INP = Input Delay + Processing Time + Presentation DelayMeasuring INP
web-vitals Library
npm install web-vitalsimport { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
console.log('Rating:', metric.rating); // 'good', 'needs-improvement', 'poor'
console.log('Attribution:', metric.attribution);
// Send to analytics
gtag('event', 'INP', {
value: Math.round(metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
});
});Chrome DevTools
1. Open DevTools
2. Performance tab
3. Record interaction
4. Look for "Interaction" events
5. Check total durationPerformance Observer
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry: any) => {
if (entry.interactionId) {
const inp = entry.duration;
console.log('Interaction:', {
type: entry.name,
duration: inp,
target: entry.target,
startTime: entry.startTime,
});
if (inp > 200) {
console.warn('Slow interaction!', inp, 'ms');
}
}
});
});
observer.observe({ type: 'event', buffered: true });Common INP Issues
1. Long Tasks (> 50ms)
// Monitor long tasks
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
console.warn('Long task detected:', {
duration: entry.duration,
startTime: entry.startTime,
});
}
});
}).observe({ type: 'longtask', buffered: true });2. Heavy Event Handlers
// ❌ BAD: Heavy synchronous work
button.addEventListener('click', () => {
const result = heavyCalculation(); // Blocks for 500ms
updateUI(result);
});
// ✅ GOOD: Break into chunks
button.addEventListener('click', async () => {
showLoading();
// Yield to browser
await new Promise(resolve => setTimeout(resolve, 0));
const result = await heavyCalculation();
updateUI(result);
hideLoading();
});3. Forced Synchronous Layouts
// ❌ BAD: Forces layout recalculation
function updateElements() {
elements.forEach(el => {
const width = el.offsetWidth; // Reads layout
el.style.width = width + 10 + 'px'; // Writes layout
// Forces layout recalc on every iteration!
});
}
// ✅ GOOD: Batch reads and writes
function updateElements() {
// Batch reads
const widths = elements.map(el => el.offsetWidth);
// Batch writes
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
}4. Third-Party Scripts
// ❌ BAD: Third-party blocks interactions
<script src="https://heavy-script.com/widget.js"></script>
// ✅ GOOD: Load after interactions
useEffect(() => {
// Load after first interaction
const loadWidget = () => {
const script = document.createElement('script');
script.src = 'https://heavy-script.com/widget.js';
script.async = true;
document.body.appendChild(script);
// Remove listeners after first load
document.removeEventListener('click', loadWidget);
document.removeEventListener('keydown', loadWidget);
};
document.addEventListener('click', loadWidget, { once: true });
document.addEventListener('keydown', loadWidget, { once: true });
}, []);Optimization Strategies
1. Break Up Long Tasks
// Break work into smaller chunks
async function processLargeDataset(data: any[]) {
const chunkSize = 100;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
// Process chunk
chunk.forEach(item => processItem(item));
// Yield to browser every 100 items
if (i + chunkSize < data.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}2. Use Scheduler API (Experimental)
// Prioritize user interactions
if ('scheduler' in window) {
// High priority (user interaction)
(window as any).scheduler.postTask(() => {
handleUserClick();
}, { priority: 'user-blocking' });
// Low priority (analytics)
(window as any).scheduler.postTask(() => {
trackAnalytics();
}, { priority: 'background' });
}3. Debounce/Throttle
import { debounce, throttle } from 'lodash';
// Debounce: Wait for user to stop
const handleSearch = debounce((query: string) => {
performSearch(query);
}, 300);
// Throttle: Limit frequency
const handleScroll = throttle(() => {
updateScrollPosition();
}, 100, { leading: true, trailing: true });
// Usage
<input onChange={(e) => handleSearch(e.target.value)} />
<div onScroll={handleScroll} />4. Web Workers
// heavy-work.worker.ts
self.addEventListener('message', (e) => {
const result = expensiveCalculation(e.data);
self.postMessage(result);
});
// Main thread
const worker = new Worker(new URL('./heavy-work.worker', import.meta.url));
button.addEventListener('click', () => {
// Immediate feedback
showLoading();
// Heavy work in worker
worker.postMessage(data);
worker.onmessage = (e) => {
updateUI(e.data);
hideLoading();
};
});5. React: useTransition
import { useState, useTransition } from 'react';
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
// Immediate update (high priority)
setQuery(value);
// Defer expensive search (low priority)
startTransition(() => {
const searchResults = expensiveSearch(value);
setResults(searchResults);
});
};
return (
<>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
/>
{isPending ? <Spinner /> : <Results data={results} />}
</>
);
}6. React: useDeferredValue
import { useState, useDeferredValue, useMemo } from 'react';
function FilteredList({ items }: Props) {
const [filter, setFilter] = useState('');
// Defer filtering (low priority)
const deferredFilter = useDeferredValue(filter);
// Expensive filtering happens with deferred value
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(deferredFilter.toLowerCase())
);
}, [items, deferredFilter]);
return (
<>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)} // Immediate
placeholder="Filter..."
/>
<List items={filteredItems} /> {/* Updates deferred */}
</>
);
}7. requestIdleCallback
// Run non-critical work when browser is idle
button.addEventListener('click', () => {
// Critical: immediate feedback
updateButtonState();
// Non-critical: defer to idle
requestIdleCallback(() => {
trackAnalytics();
prefetchNextPage();
}, { timeout: 2000 });
});8. Code Splitting
import { lazy, Suspense } from 'react';
// Load heavy component on interaction
const HeavyModal = lazy(() => import('./HeavyModal'));
function App() {
const [showModal, setShowModal] = useState(false);
return (
<>
<button onClick={() => setShowModal(true)}>
Open Modal
</button>
{showModal && (
<Suspense fallback={<LoadingModal />}>
<HeavyModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</>
);
}9. Optimize React Renders
import { memo, useCallback, useMemo } from 'react';
// Memoize expensive component
const ExpensiveComponent = memo(({ data }: Props) => {
const processedData = useMemo(() => {
return expensiveProcessing(data);
}, [data]);
return <div>{processedData}</div>;
});
// Memoize callbacks
function Parent() {
const handleClick = useCallback(() => {
// Handler logic
}, []);
return <ExpensiveComponent data={data} onClick={handleClick} />;
}10. Virtual Scrolling
import { FixedSizeList } from 'react-window';
// Only render visible items
function VirtualList({ items }: { items: any[] }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
);
}Debugging Poor INP
1. Identify Slow Interactions
// Log all slow interactions
onINP((metric) => {
if (metric.value > 200) {
console.error('Slow interaction detected!', {
inp: metric.value,
attribution: metric.attribution,
});
// Get details
const { eventTarget, eventType, loadState } = metric.attribution;
console.log('Target:', eventTarget);
console.log('Type:', eventType);
console.log('Page state:', loadState);
}
});2. Profile Interaction
// Detailed profiling
button.addEventListener('click', async (e) => {
const startTime = performance.now();
console.log('Input received');
await handleClick();
const processingTime = performance.now() - startTime;
console.log('Processing time:', processingTime, 'ms');
requestAnimationFrame(() => {
const totalTime = performance.now() - startTime;
console.log('Total INP:', totalTime, 'ms');
if (totalTime > 200) {
console.warn('Interaction exceeded 200ms threshold!');
}
});
});3. Chrome DevTools Performance Insights
1. Open DevTools
2. Performance Insights tab (Chrome 102+)
3. Record interaction
4. See breakdown of INP:
- Input delay
- Processing time
- Presentation delayReal-World Example
import { useState, useTransition, useDeferredValue } from 'react';
function ProductSearch() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);
// Immediate: Update input
const handleChange = (value: string) => {
setQuery(value);
};
// Deferred: Search results
const results = useSearch(deferredQuery);
return (
<>
<input
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Search products..."
/>
{isPending && <Spinner />}
<Results data={results} />
</>
);
}
// Custom hook with debounce
function useSearch(query: string) {
const [results, setResults] = useState([]);
useEffect(() => {
const timeout = setTimeout(async () => {
if (query) {
const data = await searchAPI(query);
setResults(data);
} else {
setResults([]);
}
}, 300);
return () => clearTimeout(timeout);
}, [query]);
return results;
}Best Practices
- Break Up Long Tasks: < 50ms each
- Use Web Workers: Heavy computation off main thread
- Debounce/Throttle: Limit expensive handlers
- Code Split: Load on interaction
- React Transitions: useTransition, useDeferredValue
- Yield to Browser: setTimeout(fn, 0) between chunks
- Batch DOM Operations: Read then write
- Virtual Scrolling: Large lists
- Memoization: Prevent unnecessary renders
- Monitor: Track INP in production
Tools
- web-vitals: Measure INP
- Chrome DevTools: Profile interactions
- Lighthouse: INP score and suggestions
- PageSpeed Insights: Field + lab data
- Web Vitals Extension: Real-time INP
Common Pitfalls
❌ Heavy click handlers: 500ms+ INP
✅ Break work into chunks
❌ Synchronous layout reads/writes: Forces reflow
✅ Batch operations
❌ No feedback: User waits blindly
✅ Show loading state immediately
❌ Everything high priority: Nothing is
✅ Use transitions for non-critical updates
❌ Third-parties block main thread: Poor INP
✅ Load after interactions or use workers
INP is the most important responsiveness metric—optimize it to keep your app feeling fast and responsive!