Interaction to Next Paint replaced First Input Delay as a Core Web Vital because FID only measured the delay before an event handler started running — it said nothing about how long the handler itself took, or the rendering work after it. A click that takes 400ms to actually update the screen passes FID with a great score and fails INP badly, which is a more honest number for what a user actually experiences as lag.

What INP actually measures

INP tracks the full latency of an interaction — from input to the next frame the browser paints in response — across the entire page lifecycle, then reports roughly the worst one (technically, a high percentile, to avoid one anomalous outlier dominating the score). A "good" INP is under 200ms; anything over 500ms is flagged as poor by Core Web Vitals thresholds.

javascript
// PerformanceObserver for 'event' entries surfaces individual slow interactions
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.log(`Slow interaction: ${entry.name}, ${entry.duration.toFixed(0)}ms`);
    }
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

Finding the actual bottleneck

Chrome DevTools' Performance panel records interactions directly and breaks each one into three phases: input delay (main thread busy with something else when the click happened), processing time (your handler actually running), and presentation delay (time to paint the result after processing finishes). Each phase points to a different fix — input delay means something unrelated is hogging the main thread at the wrong moment, processing time means the handler itself is slow, presentation delay usually means an expensive layout or paint triggered by the handler's DOM changes.

Breaking up long handlers

A synchronous handler that does everything in one go blocks the main thread from painting until it's completely finished, even if the visual update itself only needed the first few lines:

javascript
// blocks the next paint until all of this finishes
button.addEventListener('click', () => {
  updateUI();          // fast, and this is what the user is waiting to see
  logAnalytics();       // slow-ish, and irrelevant to the visible result
  recalculateReport();  // genuinely expensive
});

scheduler.yield() (or setTimeout(fn, 0) as a widely-supported fallback) hands control back to the browser between chunks of work, letting it paint before continuing:

javascript
button.addEventListener('click', async () => {
  updateUI(); // paint-critical work happens first, synchronously

  await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));

  logAnalytics();
  recalculateReport();
});

Ordering matters as much as the yield itself — do the visually necessary work first, then yield, then do the rest. Yielding before the paint-critical update accomplishes nothing.

Debouncing input-driven work

For handlers that fire on every keystroke — filtering a list, live-validating a field — debounce the expensive part rather than trying to make every single keystroke's full handler fast:

javascript
let timeoutId;
input.addEventListener('input', (e) => {
  updateInputValue(e.target.value); // instant visual feedback, every keystroke
  clearTimeout(timeoutId);
  timeoutId = setTimeout(() => runExpensiveFilter(e.target.value), 150); // debounced
});

React and framework-specific culprits

In component-based frameworks, a common INP killer is an event handler that triggers a state update cascading into a large re-render of unrelated UI. Splitting state so an interaction only re-renders what actually changed — rather than a shared parent forcing siblings to re-render too — often fixes INP issues that look like "the framework is slow" but are actually a re-render scope that's wider than it needs to be. Profile first, as with any performance problem: the fix for input delay, processing time, and presentation delay differ enough that guessing wastes the effort.