INP Explained: How to Debug the Core Web Vital That’s Killing Your Search Rankings

If you ran PageSpeed Insights on your site after March 2024 and suddenly saw a new red metric staring back at you, welcome to the INP era. Google quietly (well, not that quietly) retired First Input Delay and replaced it with Interaction to Next Paint as the responsiveness Core Web Vital. A lot of sites that had a clean green FID score discovered their INP was sitting at 600ms. That’s a ranking signal problem, and it’s time to fix it.

This article is a hands-on debugging guide. We’ll cover what INP actually measures under the hood, where most tooling lies to you, and the concrete code patterns that get you under the 200ms threshold.

What INP Actually Is (Not Just the Marketing Definition)

FID only measured the input delay for the first interaction on a page — the gap between when a user clicked something and when the browser started processing that event. One click, one measurement, done.

INP is completely different. It observes every interaction during the entire page session — every click, tap, and keypress — and reports back the worst one (technically the 98th percentile if there are more than 50 interactions, to filter out true outliers). The moment a user closes the tab, whatever the worst interaction was becomes the INP score.

The measurement itself has three phases:

Input delay — from the moment the user interacts to when the browser actually starts running your event handlers. This is usually wasted time blocked by a long task already running on the main thread.

Processing time — the time spent executing your event listeners themselves. Heavy handlers, synchronous DOM reads, expensive state updates — all of this lives here.

Presentation delay — the gap between handlers finishing and the browser painting the updated frame. This includes style recalculation, layout, and compositing.

Total INP = input delay + processing time + presentation delay.

The thresholds Google uses:

  • Good: ≤ 200ms
  • Needs Improvement: 201–500ms
  • Poor: > 500ms

200ms feels generous until you realize that includes everything from user gesture to visible screen change. On a mid-range Android phone with a few tabs open, that budget evaporates fast.

The Tooling Problem: Why Your Lab Score Lies

Here’s the first gotcha nobody talks about clearly enough.

Lighthouse does not measure INP. As of mid-2026, Lighthouse still doesn’t have a real INP audit. It will estimate Total Blocking Time, which correlates loosely, but you can have excellent TBT and terrible field INP. Don’t trust Lighthouse as your INP source of truth.

PageSpeed Insights shows field data from the Chrome UX Report (CrUX), which comes from real Chrome users visiting your actual site over the past 28 days. That number is real. The lab number shown below it is Lighthouse. They measure different things.

DevTools Performance panel is a synthetic environment. CPU throttling in DevTools doesn’t perfectly replicate real hardware. The interaction timing you see in a desktop Chrome profile is going to look better than what a Redmi 10 in Vietnam experiences.

The most accurate data you have access to is the Web Vitals JavaScript library reporting from real users into your analytics. Set that up before anything else.

// Drop this in your main bundle — it's tiny and worth it
import { onINP } from 'web-vitals';

onINP(({ value, rating, entries }) => {
  // Send to your analytics endpoint
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      metric: 'INP',
      value,
      rating, // 'good' | 'needs-improvement' | 'poor'
      // entries gives you the actual interaction data
      interactionType: entries[0]?.name,
      target: entries[0]?.target?.tagName,
    }),
  });
}, { reportAllChanges: false });

Install from npm: npm install web-vitals. The entries array in the callback is where debugging starts — it tells you which interaction was the worst and what element was involved.

Finding the Bad Interaction: Chrome DevTools Workflow

Once your field data tells you INP is a problem, you need to reproduce it locally and profile it. Here’s the workflow that actually works.

Step 1: Use the Web Vitals extension

Install the Chrome Web Vitals extension. While browsing your page, every interaction gets logged to the console with a breakdown of input delay, processing time, and presentation delay. Click around the page normally and watch which interactions spike.

Step 2: Profile the bad interaction

Once you know which interaction is slow (say, opening a dropdown or submitting a form), open DevTools → Performance tab. Check "Web Vitals" in the toolbar. Start recording, perform the slow interaction, stop recording.

You’re looking for:

  • Long tasks (red diagonal stripes in the task bar) blocking input delay
  • Fat chunks of JavaScript in the Main thread during processing time
  • Long style/layout blocks in presentation delay

Step 3: Long Animation Frames (LoAF)

The newer Long Animation Frames API is now available in Chrome and gives you much better attribution than Long Tasks alone. You can observe it directly:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn('Long animation frame detected:', {
        duration: entry.duration,
        scripts: entry.scripts.map(s => ({
          source: s.sourceURL,
          invoker: s.invoker,
          executionStart: s.executionStart,
          duration: s.duration,
        })),
      });
    }
  }
});

observer.observe({ type: 'long-animation-frame', buffered: true });

LoAF attribution is the fastest way to finger the specific script and even the specific function responsible for a slow frame. When you see a 300ms frame in your field data, LoAF tells you it was analytics.js invoking a click handler at line 847. That’s actionable.

The Real Culprits: What Actually Causes Slow INP

Long-running synchronous event handlers

The most common cause. Someone attached a click handler that does too much work before returning.

// Bad — blocks the main thread for the full duration before any paint
button.addEventListener('click', () => {
  const data = processEntireDataset(hugeArray); // 400ms
  updateDOM(data);
});

// Better — yield to the browser between chunks
button.addEventListener('click', async () => {
  // Give immediate visual feedback first
  button.classList.add('loading');
  
  await scheduler.yield(); // yield to browser — new API, widely supported now
  
  const data = await processInChunks(hugeArray);
  updateDOM(data);
  button.classList.remove('loading');
});

scheduler.yield() is your new best friend. It’s a clean way to pause a task, let the browser paint and handle other events, then resume. It replaced the setTimeout(fn, 0) hack.

React/Vue/Angular re-renders triggered by interaction

SPA frameworks are a primary source of bad INP. A click triggers a state update, which triggers a component re-render, which triggers a cascade of child re-renders, and by the time React commits to the DOM and the browser paints, you’re at 450ms.

For React specifically:

// Bad — entire tree re-renders on every keystroke
function SearchPage() {
  const [query, setQuery] = useState('');
  const results = expensiveSearch(query); // runs synchronously on every render
  
  return (
    <>
      <input onChange={e => setQuery(e.target.value)} />
      <ResultsList results={results} />
    </>
  );
}

// Better — defer expensive work with useTransition
function SearchPage() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useTransition();
  const [isPending, startTransition] = useTransition();
  
  const handleChange = (e) => {
    // Input update is urgent — happens immediately
    setQuery(e.target.value);
    
    // Search update is non-urgent — can be interrupted
    startTransition(() => {
      setDeferredQuery(e.target.value);
    });
  };
  
  const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
  
  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

useTransition tells React that this state update is low priority. The browser remains responsive for new interactions while the expensive work happens in the background.

Input delay from third-party scripts

This one hurts because it’s someone else’s code. A Google Tag Manager tag, a chat widget, an A/B testing script — any of these can have a long task running on the main thread exactly when your user clicks something.

Check: in your DevTools profile, look at the "Third Party" section and sort by blocking time. If GTM or an analytics script is responsible for 200ms of long tasks, you’ve found your problem.

The fix isn’t always to remove the script — sometimes you can delay loading it:

// Load third-party scripts after first user interaction, not on page load
let thirdPartyLoaded = false;

function loadThirdParty() {
  if (thirdPartyLoaded) return;
  thirdPartyLoaded = true;
  
  const script = document.createElement('script');
  script.src = 'https://cdn.example.com/widget.js';
  document.head.appendChild(script);
}

// Load on first interaction rather than immediately
['mousedown', 'touchstart', 'keydown', 'scroll'].forEach(event => {
  document.addEventListener(event, loadThirdParty, { once: true, passive: true });
});

For GTM specifically, audit every tag and set appropriate triggers. "All Pages — DOM Ready" for a heavyweight script is a common mistake.

Forced synchronous layouts

Happens when you read a layout property immediately after writing to the DOM, forcing the browser to recalculate layout synchronously mid-handler.

// Bad — read after write triggers forced layout
element.classList.add('expanded');
const height = element.offsetHeight; // forces synchronous layout — expensive
doSomethingWith(height);

// Better — batch reads before writes
const height = element.offsetHeight; // read first
element.classList.add('expanded');   // write after
doSomethingWith(height);

The classic pattern is also using getBoundingClientRect() inside a loop. Each call forces layout if anything has changed since the last paint. Cache the value outside the loop.

Production-Ready Monitoring Setup

Debugging once is good. Monitoring continuously is better. Here’s a minimal setup that sends INP data to your backend and lets you correlate scores with specific pages and interactions.

// vitals-monitor.js — include this early in your bundle
import { onINP, onCLS, onLCP } from 'web-vitals/attribution';

const ENDPOINT = '/api/vitals';
const PAGE_PATH = window.location.pathname;

function sendVital(metric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    path: PAGE_PATH,
    timestamp: Date.now(),
    connection: navigator.connection?.effectiveType ?? 'unknown',
  };

  // INP has rich attribution data — include it
  if (metric.name === 'INP' && metric.attribution) {
    body.interactionTarget = metric.attribution.interactionTarget;
    body.interactionType = metric.attribution.interactionType;
    body.inputDelay = metric.attribution.inputDelay;
    body.processingDuration = metric.attribution.processingDuration;
    body.presentationDelay = metric.attribution.presentationDelay;
    body.longestScript = metric.attribution.longAnimationFrameEntries?.[0]
      ?.scripts?.[0]?.sourceURL ?? null;
  }

  // Use sendBeacon for reliability — works even on page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon(ENDPOINT, JSON.stringify(body));
  } else {
    fetch(ENDPOINT, { method: 'POST', body: JSON.stringify(body), keepalive: true });
  }
}

onINP(sendVital, { reportAllChanges: false });
onCLS(sendVital);
onLCP(sendVital);

The attribution build of web-vitals gives you inputDelay, processingDuration, and presentationDelay broken out separately. That lets you segment your field data: are you failing because of input delay (main thread congestion) or processing time (heavy handlers) or presentation delay (render cost)? The fix is different for each.

Gotchas

Gotcha: INP scores in CrUX are 28-day rolling windows. After you ship a fix, you won’t see improvement in PageSpeed Insights for up to 28 days. Don’t panic. Monitor your own real-user data using the web-vitals library — you’ll see improvement immediately after deployment.

Gotcha: Mobile keyboard interactions count. Typing into a text input generates keydown, keypress, and keyup events. If your input has an expensive onChange handler (heavy filtering, API calls without debounce), every keypress is an INP candidate. A single search input with a synchronous filter over a 5,000-item array can kill your score on mobile.

Gotcha: scheduler.yield() browser support. It landed in Chrome 115 and is available in all modern browsers, but you may need a polyfill for older targets. The standard polyfill is new Promise(r => setTimeout(r, 0)), though it yields for a minimum of ~4ms rather than the "as soon as possible" behavior of the native API.

Gotcha: INP doesn’t measure hover or scroll. Only clicks, taps, and keyboard events are included. Don’t waste time optimizing mouseover handlers for INP specifically — though those still affect user experience in general.

Gotcha: iframes are excluded. Interactions inside a same-origin iframe count. Cross-origin iframes don’t. If your worst offender is inside an embedded widget, it won’t show up in your INP score — but it will still frustrate users.

The Priority Order for Fixes

If your INP is in the "poor" range and you have limited time, attack in this order:

First, check your field data for which specific interactions are slow and on which pages. Don’t guess — look at the interactionTarget and path data from your monitoring.

Second, check for third-party scripts causing main-thread congestion on those pages. Defer or delay-load them.

Third, look for synchronous event handlers doing expensive work. Move that work behind a scheduler.yield() or into a Web Worker if it’s genuinely computation-heavy.

Fourth, if you’re using a SPA framework, check render scope. An interaction that causes 300 components to re-render when only 3 needed to is a common framework-level issue. React.memo, useTransition, fine-grained signals in Vue/Solid — use what your framework provides.

Presentation delay being the dominant cost usually points to excessive DOM size or expensive CSS (transitions on properties that trigger layout like height instead of transform).

Quick Reference

INP = input delay + processing time + presentation delay

Input delay fix:    Break up long tasks, defer third-party scripts
Processing fix:     Lighter handlers, scheduler.yield(), Web Workers
Presentation fix:   Reduce DOM size, use transform/opacity for animations

Measurement tools:
  - Field data:  CrUX via PageSpeed Insights, web-vitals library
  - Lab data:    Chrome DevTools + Web Vitals extension, LoAF API
  
Thresholds:  Good ≤ 200ms  |  Needs work ≤ 500ms  |  Poor > 500ms

INP is harder to fix than FID was because it measures the worst interaction across the whole session, not just the first one. That means a page that boots fast and looks clean can still fail because of one slow click handler that users hit twenty minutes into their session. The good news is that the tooling — LoAF, the attribution build of web-vitals, scheduler.yield() — is genuinely excellent now. Instrument your real users, find the actual bad interaction, and go from there.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646