INP (Interaction to Next Paint) replaced FID as a Core Web Vital in March 2024, joining LCP and CLS as the three metrics Google uses to measure user experience. It's a harder metric to pass, and a lot of sites that were fine with FID are now failing INP.

A good INP is under 200ms. Anything over 500ms is poor. Unlike FID which only measured the first interaction, INP measures all interactions throughout the page's lifetime and reports the worst one (roughly).

The hard part is that INP is usually not fixed by one global optimization. A page can have a fast first load, a good LCP, and still fail because one menu, filter, tab switch, quantity stepper, or form field blocks the main thread after the user starts interacting.

When I audit INP, I treat it like an interaction bug, not a page-load bug. The question is not "is the app fast?" The question is "which interaction is slow, and which part of that interaction is doing too much work?"

The INP triage pattern I use

Start with this order:

StepWhat to identifyWhy it matters
1The slow interaction itselfYou need the button, input, menu, route transition, or widget that produced the bad event.
2Input delay vs processing vs presentation delayThe fix changes depending on whether the browser was blocked before, during, or after the handler.
3The longest task near the interactionThis points to the function, dependency, hydration work, or third-party script that consumed the main thread.
4Whether the issue is field-only or lab-reproducibleField-only problems often involve real devices, ads, chat widgets, consent tools, or user-specific data.

If you skip step 1, you end up applying generic JavaScript reductions and hoping the score moves. That can help, but it is slow and unreliable.

What INP actually measures

When a user clicks a button, taps a link, or presses a key, three things happen:

  1. Input delay - Time from interaction to when event handlers start running
  2. Processing time - Time spent running your JavaScript handlers
  3. Presentation delay - Time from handlers finishing to the next frame being painted

INP is the sum of all three. If any part is slow, your INP suffers.

The key insight: INP penalizes any slow interaction, not just the first one. A page that loads fast but has a sluggish dropdown menu will fail INP.

Find your slow interactions

Option 1: DevTools

  1. Open Chrome DevTools
  2. Go to Performance tab
  3. Record while interacting with the page
  4. Look for long tasks (red corners) during interactions

Option 2: Web Vitals Extension

Install the Web Vitals Chrome extension. It shows INP in real-time as you interact with the page.

Option 3: PageSpeedFix

Run your URL through PageSpeedFix - we identify interaction issues and prioritize them by impact.

Read the interaction trace

In Chrome DevTools, record the page while you repeat the suspicious interaction a few times. Then inspect the interaction entry and the long tasks around it.

Look for these patterns:

Trace patternLikely causeBetter fix
Long task before the click handler startsBackground JavaScript, hydration, third-party code, large synchronous startup work.Defer non-critical work, split hydration boundaries, delay third parties.
Click handler itself is longHeavy validation, filtering, sorting, rendering, or analytics in the handler.Move work out of the handler, chunk it, memoize, or send it to a worker.
Handler is short but paint is delayedLarge DOM update, layout recalculation, expensive CSS, or synchronous rendering.Reduce the update size, virtualize lists, batch reads/writes, simplify styles.
Only mobile is badDevice CPU pressure and heavier hydration cost.Test on throttled CPU, reduce client JavaScript, avoid large interactive bundles.

For React and Vue apps, also check whether one interaction re-renders a whole page tree. INP often fails because the handler is small but the framework work it triggers is large.

The 4 main causes of poor INP

1. Long JavaScript tasks

The browser can't respond to interactions while JavaScript is running. If you have a function that takes 300ms, any click during that time will feel sluggish.

What to do:

Break long tasks into smaller chunks using setTimeout or requestIdleCallback:

// Bad - blocks for entire duration
function processItems(items) {
  items.forEach(item => heavyOperation(item));
}

// Better - yields to browser between chunks
async function processItems(items) {
  const chunks = chunkArray(items, 50);
  for (const chunk of chunks) {
    chunk.forEach(item => heavyOperation(item));
    await new Promise(r => setTimeout(r, 0));
  }
}

For UI updates, prefer yielding after a small amount of work instead of processing a whole list in one turn:

async function processVisibleResults(items) {
  const batchSize = 25;

  for (let i = 0; i < items.length; i += batchSize) {
    renderBatch(items.slice(i, i + batchSize));
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

This does not make the total work disappear. It gives the browser chances to handle input and paint between batches, which is what INP cares about.

Or use a web worker for heavy computation:

const worker = new Worker('/heavy-task-worker.js');
worker.postMessage(data);
worker.onmessage = (e) => handleResult(e.data);

Use a worker when the task is CPU-heavy and does not need direct DOM access: parsing large JSON, scoring search results, image manipulation, diffing, or expensive formatting.

2. Too many event handlers

Every interaction triggers a cascade of event handlers. If you have handlers on multiple ancestors (event bubbling), they all run.

What to do:

Use event delegation instead of individual handlers:

// Bad - handler on every button
document.querySelectorAll('.btn').forEach(btn => {
  btn.addEventListener('click', handleClick);
});

// Better - single handler on parent
document.querySelector('.btn-container').addEventListener('click', (e) => {
  if (e.target.matches('.btn')) handleClick(e);
});

Also, remove handlers you don't need. React's synthetic events can accumulate - make sure you're cleaning up on unmount.

For forms, validate the field that changed instead of validating the whole form on every keystroke:

// Bad - validates every field on every keypress
input.addEventListener('input', () => {
  validateEntireCheckoutForm();
});

// Better - validate the field now, defer the full form check
input.addEventListener('input', (event) => {
  validateField(event.target);
  queueMicrotask(updateSubmitState);
});

For search boxes and filters, debounce network requests and expensive list filtering:

let searchTimer;

searchInput.addEventListener('input', (event) => {
  clearTimeout(searchTimer);
  searchTimer = setTimeout(() => {
    updateSearchResults(event.target.value);
  }, 150);
});

3. Forced layout recalculations

Reading layout properties (like offsetHeight) after writing them forces the browser to recalculate layout synchronously. This is called "layout thrashing."

What to do:

Batch your reads and writes:

// Bad - forces layout recalc on every iteration
elements.forEach(el => {
  const height = el.offsetHeight; // Read
  el.style.height = height + 10 + 'px'; // Write
});

// Better - batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px';
});

Use requestAnimationFrame for visual updates:

requestAnimationFrame(() => {
  element.style.transform = 'translateX(100px)';
});

Layout thrashing is common in accordions, sticky headers, carousels, and animated filters. If a component reads layout and immediately writes layout in a loop, it is a candidate for INP work.

4. Third-party scripts

Analytics, chat widgets, and ad scripts often run heavy code on interactions. They hook into click events and can add significant delay.

What to do:

  • Audit your third-party scripts with Request Map
  • Lazy load non-critical scripts
  • Use loading="lazy" for embedded content
  • Consider self-hosting critical third-party code
<!-- Bad - loads immediately -->
<script src="https://chat-widget.com/widget.js"></script>

<!-- Better - loads on interaction -->
<button onclick="loadChatWidget()">Chat with us</button>
<script>
function loadChatWidget() {
  const script = document.createElement('script');
  script.src = 'https://chat-widget.com/widget.js';
  document.body.appendChild(script);
}
</script>

For ads, do not put the ad script inside a high-frequency interaction path. Reserve the ad slot, load the ad library outside click/input handlers, and avoid layout work when the ad fills. That protects both INP and CLS.

React and framework-specific INP traps

React

useMemo and useCallback help only when they prevent real work. They do not fix a slow interaction by themselves.

Look for:

  • global state updates that re-render the entire app
  • expensive derived arrays calculated during render
  • table filtering or sorting on every keypress
  • inline object props that break memoization
  • large controlled forms where one field update re-renders every field

For non-urgent UI updates in React, use useTransition:

import { useMemo, useState, useTransition } from 'react';

export function ProductSearch({ products }) {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const filtered = useMemo(() => {
    return products.filter((product) =>
      product.name.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [products, deferredQuery]);

  function handleChange(event) {
    const nextQuery = event.target.value;
    setQuery(nextQuery);
    startTransition(() => setDeferredQuery(nextQuery));
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <span>Updating...</span> : null}
      <ProductList products={filtered} />
    </>
  );
}

The input stays responsive while the heavier list update becomes lower priority.

Next.js

Poor INP in Next.js often comes from too many Client Components in the initial route. If a component does not need useState, event handlers, browser APIs, or effects, keep it as a Server Component. Then dynamically import heavy interactive pieces:

import dynamic from 'next/dynamic';

const ProductComparison = dynamic(() => import('./ProductComparison'), {
  loading: () => <ComparisonSkeleton />,
});

Nuxt and Vue

In Nuxt, avoid turning server-rendered content into a large <ClientOnly> block. Keep the content server-rendered and isolate the browser-only control:

<template>
  <ProductDetails :product="product" />
  <ClientOnly>
    <InteractiveConfigurator :product-id="product.id" />
  </ClientOnly>
</template>

For large lists, use computed properties carefully and consider virtualization when the DOM itself becomes the bottleneck.

Framework-specific tips

React:

  • Use useMemo and useCallback to prevent unnecessary re-renders
  • Use React.lazy() for code splitting
  • Avoid inline function definitions in JSX

Next.js:

  • Use dynamic imports: dynamic(() => import('./HeavyComponent'))
  • Leverage server components (RSC) to reduce client JS

Nuxt:

  • Use <ClientOnly> for client-heavy components
  • Lazy load with defineAsyncComponent

Quick checklist

  • Are there any JavaScript tasks over 50ms during interactions?
  • Are you using event delegation where possible?
  • Are layout reads and writes batched?
  • Are third-party scripts lazy loaded?
  • Are heavy computations moved to web workers?
  • Did you identify the exact slow interaction, not just the page?
  • Does one state update re-render a large tree?
  • Are search/filter/table interactions debounced or deferred?
  • Are ads, analytics, and chat scripts kept out of interaction handlers?
  • Did you test on a throttled CPU or real mid-range mobile device?

What to compare after an INP fix

Use before-and-after evidence that captures the interaction, not just the page load:

  1. DevTools Performance recording while repeating the slow action.
  2. Long task count and duration around that interaction.
  3. Bundle diff if the fix removed client JavaScript.
  4. Field data after deployment, because INP is highly device- and user-dependent.

If the lab trace improves but field INP does not, sample more real interactions. The slow action may be a widget, consent flow, ad event, or logged-in state that your lab test did not reproduce.

Frequently Asked Questions

What is a good INP score?

A good INP is under 200 milliseconds. Scores between 200-500ms need improvement, and anything over 500ms is considered poor. INP measures responsiveness across all user interactions, not just the first one.

What's the difference between INP and FID?

FID (First Input Delay) only measured the delay before the first interaction. INP measures all interactions throughout the page lifecycle and reports the worst one. This makes INP harder to pass but more representative of real user experience.

Why is my INP failing when my site feels fast?

INP catches slow interactions that happen after initial load - like sluggish dropdown menus, slow form submissions, or laggy scrolling. Your site might load fast but have interaction issues that only INP reveals.

How do I find slow interactions?

Run your site through PageSpeedFix to identify interaction bottlenecks. You can also use Chrome DevTools Performance panel - record while interacting and look for long tasks (functions taking over 50ms).

Do third-party scripts affect INP?

Yes, significantly. Analytics, chat widgets, and ad scripts often hook into click events and run heavy code. Audit your third-party scripts and lazy load non-critical ones to improve INP.

What's next

Want to identify exactly which interactions are slow on your site? Run your URL through PageSpeedFix - we'll show you the specific issues and give you framework-aware fixes.

D
Declan

Software engineer in Melbourne. Writes guides at PageSpeedFix and builds web performance tools. More at dekk.dev.