DevgainsDevgainsDevgains
All articles

INP Is the Core Web Vital That Quietly Broke Your Lighthouse Score

·4 min read·Updated Jun 30, 2026
INP Is the Core Web Vital That Quietly Broke Your Lighthouse Score

Cover: gradient generated for Devgains

In March 2024, Interaction to Next Paint (INP) became a Core Web Vital, replacing First Input Delay (FID). A lot of teams didn't notice until their field data quietly turned yellow. FID was easy to pass — it only measured the delay before an event handler started. INP measures the whole thing: input delay, processing time, and the time to render the next frame, across all interactions on the page.

In other words: FID graded you on picking up the phone. INP grades you on the conversation. If you're new to the three metrics, start with the complete guide to Core Web Vitals for how LCP, INP, and CLS fit together; this article zooms in on INP.

What INP actually measures

For every tap, click, and keypress, the browser measures the time from the interaction to the next frame painted in response. INP reports (roughly) the worst interaction on the page. The official thresholds are:

  • Good: ≤ 200 ms
  • Needs improvement: 200–500 ms
  • Poor: > 500 ms

An interaction breaks into three parts, and you need to know which one is hurting you:

interaction → [ input delay ] → [ processing time ] → [ presentation delay ] → next paint
                main thread busy    your event handlers     rendering + layout

Most failing INP is processing time (your handlers doing too much) or input delay (the main thread was already busy when the user clicked).

Stop blocking the main thread

The number one cause of bad INP is long tasks — any task that occupies the main thread for more than 50 ms. While one runs, the browser literally cannot respond to input. web.dev's "optimize long tasks" guide is the canonical reference, and the core move is yielding.

Break long work into chunks and yield to the browser between them. The modern primitive is scheduler.yield(), with a setTimeout fallback:

async function yieldToMain() {
  if ("scheduler" in window && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}
 
async function processAll(items) {
  for (const item of items) {
    handle(item);
    // Let the browser paint and respond to input between items.
    if (navigator.scheduling?.isInputPending?.()) {
      await yieldToMain();
    }
  }
}

Yielding doesn't make the work faster — it makes the page responsive while the work happens. INP rewards responsiveness, not raw throughput.

Decouple the visual response from the expensive work

When a user clicks, give them feedback on the next frame. Do the heavy work after. The pattern: update the UI to its pending state, yield, then run the expensive logic.

button.addEventListener("click", async () => {
  button.classList.add("is-loading");   // cheap, paints immediately
  await yieldToMain();                   // let that frame render
  await doExpensiveThing();              // now do the slow part
  button.classList.remove("is-loading");
});

This single inversion fixes a huge share of "the button feels laggy" complaints, because the user sees a response in well under 200 ms even if the real work takes a second.

React, hydration, and the framework tax

If you're on React, two things dominate INP:

  • Hydration cost. Server-rendered HTML isn't interactive until JS hydrates it. Ship less JS and hydrate less — React Server Components exist largely to cut the client bundle. The React docs on Server Components are the starting point.
  • Synchronous renders on input. A setState that triggers a large re-render on every keystroke is an INP killer. Mark non-urgent updates with useTransition / useDeferredValue so typing stays responsive while the expensive list re-renders in the background.
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
// Input updates immediately; the heavy filtered list reads deferredQuery.
const results = useMemo(() => filter(items, deferredQuery), [items, deferredQuery]);

Measure it in the field, not just the lab

INP is a field metric. Lighthouse can estimate it, but only real users produce a real INP. Capture it with the web-vitals library and send it somewhere you can segment by page and device:

import { onINP } from "web-vitals";
 
onINP((metric) => {
  navigator.sendBeacon("/vitals", JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    path: location.pathname,
  }));
});

Then look at the 75th percentile on real devices. Your M-series laptop is not your median visitor; a mid-range Android phone from three years ago is.

The short version

  • INP replaced FID and measures full interaction responsiveness, p ~worst-case.
  • Most failures are long tasks on the main thread — yield to fix them.
  • Paint the visual response first, do expensive work after a yield.
  • On React, cut hydration cost and defer non-urgent renders.
  • Measure p75 INP in the field with web-vitals, not just in Lighthouse.

If your site "got slow" without you changing anything, you didn't get slower — the ruler changed. INP just started measuring the part users actually feel.

4 min read

Read next