What INP Measures
Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the delay before the first interaction's event handler ran, INP measures the full round-trip latency of every interaction — from user input to the next frame painted on screen — and reports the worst one (technically the 98th percentile across the session).
| Rating | Threshold | Meaning |
|---|---|---|
| Good | ≤ 200ms | User perceives the interaction as instant |
| Needs Improvement | 200–500ms | Noticeable lag, especially on mobile |
| Poor | > 500ms | Interface feels broken or frozen |
What counts as an interaction: clicks, taps, and keyboard input (keydown/keyup). Scrolling and hovering do not count.
The Three Phases of Interaction Latency
Every interaction has three measurable phases. Fixing INP requires knowing which phase is slow:
┌─────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ Input Delay │ → │ Processing Time │ → │ Presentation Delay │
│ │ │ │ │ │
│ Time waiting │ │ Event handler │ │ Style recalc, layout,│
│ for main │ │ execution time │ │ paint, compositing │
│ thread │ │ (your code) │ │ (rendering work) │
└─────────────┘ └─────────────────┘ └──────────────────────┘
Input delay — The interaction happened but the main thread was busy (running another script, parsing CSS, etc.). The browser queues the event until the thread is free. This phase is slow when long tasks block the main thread.
Processing time — Your event handler code runs. This is the part you have direct control over. Slow processing means your click/keypress handler does too much work synchronously.
Presentation delay — After your handler finishes, the browser must recalculate styles, run layout, paint, and composite the new frame. This phase is slow when your handler triggers expensive DOM changes, large layout shifts, or paints many elements.
Diagnosing INP
Step 1: Identify the Slow Interaction
// Use web-vitals library to capture INP with attribution
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
console.log('INP value:', metric.value, 'ms');
console.log('Rating:', metric.rating);
const entry = metric.attribution.eventEntry;
console.log('Event type:', metric.attribution.eventType);
console.log('Event target:', metric.attribution.eventTarget);
// Phase breakdown
console.log('Input delay:', metric.attribution.inputDelay, 'ms');
console.log('Processing:', metric.attribution.processingDuration, 'ms');
console.log('Presentation:', metric.attribution.presentationDelay, 'ms');
});
This tells you: what element was interacted with, what type of interaction it was, and which of the three phases took the most time. That determines your fix.
Step 2: Use Long Animation Frames API
The Long Animation Frames (LoAF) API shows exactly what scripts ran during slow frames, replacing the older Long Tasks API with much more useful attribution:
// Observe long animation frames (>50ms)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration = total frame time
// entry.blockingDuration = time the frame blocked input
console.log(`LoAF: ${entry.duration}ms (blocking: ${entry.blockingDuration}ms)`);
// Which scripts contributed?
for (const script of entry.scripts) {
console.log(` Script: ${script.sourceURL}`);
console.log(` Function: ${script.sourceFunctionName}`);
console.log(` Duration: ${script.duration}ms`);
console.log(` Type: ${script.invokerType}`); // 'event-listener', 'user-callback', etc.
console.log(` Invoker: ${script.invoker}`); // e.g., 'BUTTON#submit.onclick'
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
Browser support: Chrome 123+ (March 2024). Not yet in Firefox or Safari. Use as a diagnostic tool, not a production dependency.
Step 3: Chrome DevTools Performance Panel
- Open DevTools → Performance → check "Web Vitals" checkbox.
- Click Record, perform the slow interaction, stop recording.
- In the timeline, look for the interaction marker (labeled "INP" if it's the worst).
- Zoom into the interaction. You'll see:
- Input delay: gap between the interaction marker and when the event handler starts.
- Processing: the event handler task(s) in the flame chart.
- Presentation: style recalc, layout, paint, and composite tasks after the handler.
- Click on long tasks in the flame chart to see which functions consumed time.
Step 4: Field Data (Real Users)
Lab tools often miss real INP because they can't replicate all user interaction patterns. Use field data:
- Chrome User Experience Report (CrUX):
chrome-ux-reportBigQuery dataset or PageSpeed Insights — shows 75th percentile INP from real Chrome users. - Google Search Console: Core Web Vitals report flags pages with poor INP.
- RUM tools: Send INP data to your analytics platform using the web-vitals library (shown above).
Fixing Input Delay
Input delay is caused by long tasks occupying the main thread when the user interacts. The user's event handler can't run until the current task finishes.
Yield to the Main Thread
Break long tasks into smaller chunks so the browser can process pending interactions between them:
// Before: one long synchronous task
function processLargeDataset(items) {
items.forEach(item => expensiveOperation(item));
}
// After: yield between chunks using scheduler.yield()
async function processLargeDataset(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Yield to let browser process pending interactions
await scheduler.yield(); // Chrome 129+
// Fallback: await new Promise(resolve => setTimeout(resolve, 0));
}
}
scheduler.yield() is purpose-built for this — it yields to the browser but keeps your task's priority in the queue (unlike setTimeout(0) which drops to the back of the queue).
Reduce Third-Party Script Impact
Third-party scripts (analytics, ads, chat widgets) are the most common cause of input delay because they run long tasks on the main thread that you don't control:
<!-- Load non-critical third-party scripts after the page is interactive -->
<script src="https://example.com/widget.js" async defer></script>
<!-- Or load via requestIdleCallback -->
<script>
requestIdleCallback(() => {
const s = document.createElement('script');
s.src = 'https://example.com/chat-widget.js';
document.head.appendChild(s);
});
</script>
Code Splitting
Large JavaScript bundles that parse on page load create long tasks that cause input delay for early interactions:
// Before: everything loaded upfront
import { heavyChartLibrary } from './charts';
button.addEventListener('click', () => {
heavyChartLibrary.render(data);
});
// After: load on demand
button.addEventListener('click', async () => {
const { heavyChartLibrary } = await import('./charts');
heavyChartLibrary.render(data);
});
Fixing Processing Time
Processing time is the part you directly control — it's how long your event handler takes to execute.
Keep Event Handlers Lean
// Before: handler does everything synchronously
button.addEventListener('click', () => {
const data = computeExpensiveAnalytics(); // 80ms
updateDOM(data); // 40ms
sendToServer(data); // network call
logInteraction(); // 10ms
});
// Total: 130ms+ of synchronous work
// After: only do visual updates synchronously
button.addEventListener('click', () => {
// Immediate visual feedback
button.classList.add('loading');
// Defer non-visual work
requestIdleCallback(() => {
const data = computeExpensiveAnalytics();
sendToServer(data);
logInteraction();
});
// Schedule DOM update for next frame
requestAnimationFrame(() => {
updateDOM(computeQuickPreview());
});
});
Move Heavy Computation to Web Workers
// worker.js
self.onmessage = ({ data }) => {
const result = expensiveComputation(data);
self.postMessage(result);
};
// main.js
const worker = new Worker('worker.js');
button.addEventListener('click', () => {
button.classList.add('loading'); // instant visual feedback
worker.postMessage(inputData);
});
worker.onmessage = ({ data }) => {
updateDOM(data);
button.classList.remove('loading');
};
Debounce Rapid Interactions
Keyboard input (search boxes, autocomplete) fires events on every keystroke:
// Before: runs on every keypress
input.addEventListener('input', (e) => {
const results = searchDatabase(e.target.value); // expensive
renderResults(results);
});
// After: debounce to avoid processing every keystroke
let timeout;
input.addEventListener('input', (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const results = searchDatabase(e.target.value);
renderResults(results);
}, 150);
});
Fixing Presentation Delay
Presentation delay is caused by expensive rendering work after your event handler completes — large layout recalculations, painting many elements, or forced synchronous layouts.
Avoid Layout Thrashing
// BAD: forces synchronous layout on every iteration
elements.forEach(el => {
const height = el.offsetHeight; // forces layout (read)
el.style.height = (height * 2) + 'px'; // invalidates layout (write)
// Next iteration's offsetHeight forces another layout
});
// GOOD: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // all reads
elements.forEach((el, i) => {
el.style.height = (heights[i] * 2) + 'px'; // all writes
});
Reduce DOM Size
Pages with more than 1,400 DOM elements have measurably slower rendering. Pages over 3,000 elements are consistently poor:
// Check your DOM size
console.log('DOM elements:', document.querySelectorAll('*').length);
Fixes: virtualize long lists (only render visible rows), remove hidden elements instead of using display: none, lazy-load off-screen content with content-visibility: auto.
Use CSS Containment
Tell the browser that changes inside a container won't affect elements outside it:
/* The browser can skip recalculating layout/style/paint
for this element when something else on the page changes */
.card {
contain: layout style paint;
}
/* content-visibility skips rendering entirely for off-screen elements */
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* estimated height to prevent CLS */
}
Framework-Specific Patterns
React
// Use useTransition for non-urgent state updates
import { useTransition } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent: update input immediately
startTransition(() => {
setResults(filterData(e.target.value)); // non-urgent: can be interrupted
});
}
return (
<>
<input value={query} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</>
);
}
Vue
// Use v-memo to skip re-rendering unchanged list items
// <div v-for="item in list" :key="item.id" v-memo="[item.id, item.updated]">
// Use shallowRef for large objects that don't need deep reactivity
import { shallowRef } from 'vue';
const largeDataset = shallowRef(initialData);
// Trigger update explicitly
largeDataset.value = { ...largeDataset.value, newProp: 'value' };
Next.js / React Server Components
// Move data-heavy rendering to server components (zero client JS)
// app/dashboard/page.tsx (server component by default)
export default async function Dashboard() {
const data = await fetchDashboardData();
return <DashboardView data={data} />; // rendered on server, no INP impact
}
// Only interactive parts become client components
'use client';
export function FilterDropdown({ options }) {
// This is the only JS sent to the client
const [selected, setSelected] = useState(options[0]);
return <select => setSelected(e.target.value)}>...</select>;
}
Measuring Progress
After making changes, verify improvement:
- Lab check: Run Lighthouse with "Timespan" mode (not just Navigation) to capture interaction INP during a recorded session.
- Field validation: Deploy changes, wait 28 days for CrUX data to update, check PageSpeed Insights or Search Console.
- RUM monitoring: Use the web-vitals attribution build in production to continuously track INP and identify regressions.
Track INP alongside the phase breakdown (input delay / processing / presentation) — raw INP alone doesn't tell you what to fix next.