How to Fix INP: A Practical Guide to Interaction to Next Paint

2026-03-06 · Nico Brandt

INP replaced FID as a Core Web Vital in March 2024, and roughly 43% of websites still fail the threshold. If your site is one of them, you’ve probably noticed: PageSpeed Insights went from green to orange, and the advice it gives you is maddeningly vague.

“Reduce JavaScript execution time.” Thanks. Very helpful.

Here’s what actually works. This is a practical INP debugging tutorial – not the theory, but the workflow I use to find and fix INP issues on real production sites. If you want the broader picture first, start with the web performance checklist. This article goes deep on the one metric most sites are failing.

What INP Actually Measures

INP tracks how long it takes your page to visually respond after a user interacts with it. Click a button, type in a field, tap a menu – INP measures the gap between that action and the next frame the browser paints.

The thresholds:

INP Score Rating
200ms or less Good
200-500ms Needs Improvement
Over 500ms Poor

Unlike FID (which only measured the first interaction), INP measures every interaction during a page visit and reports roughly the worst one. That’s why sites that passed FID are failing INP – your first click might be fast, but your fifteenth click after the page has loaded six analytics scripts is another story.

The measurement breaks into three parts: input delay (how long before your handler runs), processing time (how long your handler takes), and presentation delay (how long before the browser paints). To fix INP core web vitals issues, you need to know which part is slow. Usually it’s all three, but one dominates.

Finding Your Worst Interactions

Don’t guess. Measure.

Step 1: Google Search Console. Go to Core Web Vitals report. It shows which URL groups fail INP based on real user data from the Chrome UX Report. Start with the pages that have the most traffic and the worst scores.

Step 2: web-vitals library with attribution. Add this to your production site:

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  const entry = metric.attribution;
  console.log({
    value: metric.value,
    element: entry.interactionTarget,
    type: entry.interactionType,
    inputDelay: entry.inputDelay,
    processingDuration: entry.processingDuration,
    presentationDelay: entry.presentationDelay
  });
  // Send to your analytics endpoint
});

This tells you the exact element, interaction type, and timing breakdown for every slow interaction in production. It’s the single most useful piece of INP debugging data you’ll get.

Step 3: Chrome DevTools Performance tab. Record a session, interact with the slow elements, and look for long tasks (yellow bars over 50ms). The “Interactions” track shows each interaction with its INP breakdown.

This three-step workflow – Search Console for which pages, web-vitals for which elements, DevTools for which code – is how I fix INP core web vitals issues without guessing.

The Fixes That Actually Move the Needle

Most INP problems come from three sources: your event handlers do too much work, third-party scripts block the main thread, or the browser has to do expensive layout calculations after your handler runs. Here’s how to fix INP core web vitals failures, ordered by impact.

Yield the Main Thread in Event Handlers

The biggest single fix for most sites. If your click handler does computation followed by a DOM update, the browser can’t paint until all of it finishes. Break it up:

// Before: browser can't paint until everything finishes
button.addEventListener('click', () => {
  processLargeDataset(items);  // 200ms
  updateAnalytics();            // 50ms
  renderResults();              // 100ms
  // INP: 350ms (everything blocks)
});

// After: paint happens after immediate feedback
button.addEventListener('click', async () => {
  showLoadingState();           // Immediate visual feedback
  await scheduler.yield();      // Let browser paint
  processLargeDataset(items);   // Runs after paint
  updateAnalytics();
  renderResults();
  // INP: ~16ms (just the loading state)
});

scheduler.yield() is available in Chrome 129+. For cross-browser support:

function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }
  return new Promise(resolve => {
    requestAnimationFrame(() => setTimeout(resolve));
  });
}

This pattern alone fixed a 450ms INP down to 80ms on a project I worked on last year. The key insight: give immediate visual feedback first, then do the heavy work.

Stop Layout Thrashing

If you read a DOM property (like offsetHeight) and then write to the DOM (like style.height), the browser has to recalculate layout between each pair. In a loop, this destroys performance:

// Bad: forces layout recalculation on every iteration
for (let i = 0; i < elements.length; i++) {
  const height = elements[i].offsetHeight;  // Read (forces layout)
  elements[i].style.height = (height + 10) + 'px';  // Write
}

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

If you suspect layout thrashing, look for purple “Layout” bars in the DevTools Performance tab that appear repeatedly inside a single task.

Debounce and Defer Input Handlers

Search inputs and scroll handlers fire dozens of times per second. Each one blocks the thread:

// Bad: filters on every keystroke
searchInput.addEventListener('input', (e) => {
  const results = largeArray.filter(item =>
    item.name.includes(e.target.value)
  );
  renderResults(results);
});

// Good: debounce the expensive work
searchInput.addEventListener('input', (e) => {
  setInputValue(e.target.value);  // Update input immediately
  clearTimeout(searchTimeout);
  searchTimeout = setTimeout(() => {
    const results = largeArray.filter(item =>
      item.name.includes(e.target.value)
    );
    renderResults(results);
  }, 150);
});

React-Specific Fixes

If you’re running React, useTransition is your best friend for interaction to next paint optimization:

import { useTransition, useState } from 'react';

function SearchResults({ data }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(data);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    setQuery(e.target.value);  // High priority: update input
    startTransition(() => {
      // Low priority: filter and re-render list
      setResults(data.filter(item =>
        item.name.includes(e.target.value)
      ));
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <span>Filtering...</span>}
      <ResultsList items={results} />
    </>
  );
}

startTransition tells React that the wrapped state update is non-urgent. React renders the input change immediately and defers the expensive list re-render. The user sees their keystroke reflected instantly while the results catch up.

For pages with long lists, consider virtualization. Rendering 1000 DOM nodes when only 20 are visible is a common INP killer. Libraries like @tanstack/react-virtual solve this without changing your data model.

Containing Third-Party Script Damage

Here’s the uncomfortable truth about INP: most of the worst offenders are scripts you don’t control. Google Tag Manager triggers, analytics libraries, chat widgets, consent banners – they all run JavaScript on the main thread during user interactions.

Use the facade pattern for heavy embeds. Don’t load YouTube iframes, chat widgets, or social embeds until the user actually clicks on them:

<div class="video-facade" onclick="loadVideo(this)" data-id="abc123">
  <img src="thumbnail.jpg" alt="Video" loading="lazy" />
  <button aria-label="Play video">Play</button>
</div>

<script>
function loadVideo(el) {
  const iframe = document.createElement('iframe');
  iframe.src = `https://youtube.com/embed/${el.dataset.id}?autoplay=1`;
  iframe.allow = 'autoplay';
  el.replaceWith(iframe);
}
</script>

Move analytics off the interaction path. If your click handlers push to dataLayer synchronously, every GTM trigger fires during the interaction:

// Bad: analytics runs during the interaction
button.addEventListener('click', () => {
  dataLayer.push({ event: 'cta_click' });  // Triggers GTM tags
  navigateToNextPage();
});

// Better: defer analytics
button.addEventListener('click', async () => {
  navigateToNextPage();
  await yieldToMain();
  dataLayer.push({ event: 'cta_click' });  // After paint
});

You can’t eliminate third-party impact entirely. But you can move it out of the critical interaction path. That’s usually enough to improve website responsiveness 2026 standards require.

Validating Your Fixes

Don’t ship performance fixes without measuring the before and after. Here’s the workflow:

  1. Record baseline INP with web-vitals library for at least 48 hours (need real user data across devices).
  2. Deploy fixes to a percentage of traffic if you can. A/B test performance changes like features.
  3. Check Search Console after 28 days – CrUX data updates on a rolling window.
  4. Set up alerts for regressions. INP can degrade silently when someone adds a new event listener or a third-party script updates.

For local testing, Chrome DevTools with 4x CPU throttling simulates mid-range mobile devices. Your M-series MacBook with a 120Hz display will not show you INP problems that affect 60% of your users on Android.

The target: get every page below 200ms at the 75th percentile. If you’re currently above 500ms, focus on getting below 500 first. The jump from “Poor” to “Needs Improvement” is where the biggest core web vitals INP fixes pay off – both for user experience and search rankings. QuintoAndar reduced their INP by 80% and saw conversions increase 36%. Those aren’t vanity metrics.

If your site has broader performance issues beyond INP, I’ve written about why websites are slow and it’s usually not what you think.

Your users don’t care about your Lighthouse score. They care that the button they clicked actually did something. INP measures that gap. Close it.