Skip to main content
Core Web Vitals 2025 Update

INP Optimization Guide 2025: Fix Interaction to Next Paint

Complete guide to optimizing Interaction to Next Paint (INP), Google's newest Core Web Vital. Learn why your site fails INP and get code fixes that work.

Chris Drinkard

Written by

14 years in hospitality management

5 years helping independent hotels grow their online presence. Passionate about AI, search, and digital strategy.

⚠️ Critical Update: INP (Interaction to Next Paint) officially replaced FID as a Core Web Vital in March 2024. If your site passed Core Web Vitals before but fails now, INP is likely the culprit. This guide shows you exactly how to diagnose and fix INP issues.

What Is INP (Interaction to Next Paint)?

Interaction to Next Paint (INP) measures how quickly your page responds to user interactions. It tracks the time from when a user clicks, taps, or presses a key until the browser paints the visual response.

INP Measures Three Phases:

  1. Input Delay: Time from user interaction until event handlers start running
  2. Processing Time: Time spent executing event handler callbacks
  3. Presentation Delay: Time from event handlers completing until browser paints the update

INP = Input Delay + Processing Time + Presentation Delay

Good

≤ 200ms

Needs Improvement

200-500ms

Poor

> 500ms

Unlike other metrics that measure a single moment, INP tracks every interaction throughout a page visit. It then reports the worst interaction (at the 98th percentile for pages with many interactions, or the single worst for pages with few).

INP vs FID: Why Google Made the Change

FID (First Input Delay) only measured the first interaction and only the input delay phase. This had major blindspots:

Aspect FID (Old) INP (New)
Interactions Measured First only All interactions
What's Measured Input delay only Full interaction lifecycle
Good Threshold ≤ 100ms ≤ 200ms
Typical Problem Startup JavaScript Heavy event handlers, re-renders
Framework Impact Moderate Significant

Why Many Sites Now Fail Core Web Vitals

A site could have perfect FID (first click responds fast) but terrible INP (later interactions are slow). This is common with SPAs, heavy React/Vue apps, and sites with complex interactive features. When Google switched to INP in March 2024, many "passing" sites suddenly failed.

How to Measure Your INP Score

Lab Tools (Simulated)

  • Chrome DevTools: Performance panel → Check "Interactions" in timeline
  • Lighthouse: Shows estimated INP in Performance audit (but limited to interactions you trigger)
  • PageSpeed Insights: Shows both lab simulation and field data

Field Tools (Real Users)

  • Google Search Console: Core Web Vitals report → INP status
  • PageSpeed Insights: "Field Data" section (if available)
  • Chrome UX Report (CrUX): Public dataset of real user metrics
  • web-vitals library: Add to your site for custom RUM

// Add INP monitoring with web-vitals library

import {onINP} from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value, 'ms');
  console.log('Interaction:', metric.entries[0]?.name);
  
  // Send to your analytics
  sendToAnalytics({
    name: 'INP',
    value: metric.value,
    rating: metric.rating, // 'good', 'needs-improvement', 'poor'
    interaction: metric.entries[0]?.name
  });
});

💡 Pro Tip: Use the Web Vitals Extension

Install the Web Vitals Chrome extension to see INP in real-time as you interact with any page. Click around your site to find which interactions cause high INP.

Diagnosing INP Problems

Before fixing INP, you need to identify which interactions are slow and why. Here's a systematic approach:

Step 1: Find Slow Interactions

  1. Open Chrome DevTools → Performance tab
  2. Enable "Interactions" track (click gear icon)
  3. Record while interacting with your page
  4. Look for red/yellow interaction bars (slow)
  5. Click an interaction to see breakdown

Step 2: Identify the Slow Phase

Each interaction has three phases. Identify which is longest:

Input Delay

Main thread was busy when user clicked

Fix: Break up long tasks, defer non-critical JS

Processing Time

Event handler took too long

Fix: Optimize handler code, reduce work

Presentation Delay

Rendering/painting took too long

Fix: Simplify DOM updates, reduce layout thrashing

Common Culprits

  • Accordion/Tab clicks: Re-rendering large content sections
  • Form submissions: Validation + API calls blocking
  • Navigation menu: Complex animations or layout changes
  • Infinite scroll: Loading + rendering new items
  • Filter/Sort: Re-rendering large lists
  • Modal opens: Lazy-loaded content + overlay rendering

Fix #1: Break Up Long JavaScript Tasks

Any JavaScript task over 50ms is a "Long Task" that blocks the main thread. During a long task, the browser can't respond to user input, causing input delay.

Use scheduler.yield() (Modern Approach)

// Before: One long task
function processItems(items) {
  for (const item of items) {
    doExpensiveWork(item); // Blocks for entire loop
  }
}

// After: Yield to browser between chunks
async function processItems(items) {
  for (const item of items) {
    doExpensiveWork(item);
    
    // Let browser handle pending interactions
    if ('scheduler' in window) {
      await scheduler.yield();
    }
  }
}

Use setTimeout for Older Browsers

// Polyfill for scheduler.yield()
function yieldToMain() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

async function processItems(items) {
  const CHUNK_SIZE = 10;
  
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => doExpensiveWork(item));
    
    await yieldToMain(); // Let browser breathe
  }
}

Use requestIdleCallback for Non-Urgent Work

// Defer analytics, tracking, prefetching
requestIdleCallback(() => {
  sendAnalytics();
  prefetchNextPage();
}, { timeout: 2000 }); // Max 2s wait

Fix #2: Optimize Event Handlers

Event handlers run during the "processing time" phase. Long handlers = high INP.

Debounce Rapid-Fire Events

// Before: Handler runs on every keystroke
input.addEventListener('input', (e) => {
  searchAPI(e.target.value); // Too frequent!
});

// After: Debounce to reduce calls
function debounce(fn, delay) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(...args), delay);
  };
}

input.addEventListener('input', debounce((e) => {
  searchAPI(e.target.value);
}, 300));

Separate Visual Updates from Data Processing

// Before: Everything in one handler
button.addEventListener('click', async () => {
  showLoadingSpinner();
  const data = await fetchData(); // Blocking!
  updateUI(data);
  sendAnalytics();
});

// After: Immediate visual feedback, defer the rest
button.addEventListener('click', () => {
  // Immediate: Visual feedback (fast INP)
  showLoadingSpinner();
  
  // Deferred: Heavy work
  requestAnimationFrame(async () => {
    const data = await fetchData();
    updateUI(data);
    
    requestIdleCallback(() => {
      sendAnalytics();
    });
  });
});

Avoid Synchronous Storage Access

// Bad: Synchronous localStorage in handler
button.addEventListener('click', () => {
  const prefs = JSON.parse(localStorage.getItem('prefs')); // Blocks!
  applyPreferences(prefs);
});

// Good: Read storage outside handlers, cache in memory
let cachedPrefs = null;

// Load once on page start
requestIdleCallback(() => {
  cachedPrefs = JSON.parse(localStorage.getItem('prefs') || '{}');
});

button.addEventListener('click', () => {
  applyPreferences(cachedPrefs); // Fast - already in memory
});

Fix #3: Tame Third-Party Scripts

Third-party scripts (analytics, ads, chat widgets) often run long tasks that block interactions. They're the #1 cause of poor INP on otherwise well-optimized sites.

Audit Third-Party Impact

  1. Open DevTools → Performance tab → Record page load
  2. Look for long tasks (red bars) in the flame chart
  3. Expand tasks to see which scripts caused them
  4. Third-party domains are usually obvious (googletagmanager.com, etc.)

Load Third-Party Scripts After Interaction

// Load chat widget only after user scrolls or clicks
let chatLoaded = false;

function loadChatWidget() {
  if (chatLoaded) return;
  chatLoaded = true;
  
  const script = document.createElement('script');
  script.src = 'https://chat-widget.com/widget.js';
  document.body.appendChild(script);
}

// Trigger on interaction or scroll
window.addEventListener('scroll', loadChatWidget, { once: true });
document.addEventListener('click', loadChatWidget, { once: true });

// Or after a delay
setTimeout(loadChatWidget, 5000);

Use loading="lazy" for Embeds

<!-- YouTube embeds: Use facade pattern -->
<lite-youtube videoid="VIDEO_ID"></lite-youtube>

<!-- Or native lazy loading for iframes -->
<iframe 
  src="https://www.youtube.com/embed/VIDEO_ID" 
  loading="lazy"
  title="Video title"
></iframe>

Third-Party Scripts to Audit

  • • Google Tag Manager (GTM) with many tags
  • • Live chat widgets (Intercom, Drift, Zendesk)
  • • Heatmap tools (Hotjar, FullStory, Clarity)
  • • A/B testing (Optimizely, VWO)
  • • Advertising scripts (Google Ads, Facebook Pixel)
  • • Social widgets (Twitter embeds, Facebook Like buttons)

Fix #4: Reduce Rendering Work

The "presentation delay" phase measures how long it takes to paint visual updates. Large DOM trees and layout thrashing are common culprits.

Avoid Layout Thrashing

// Bad: Read-write-read-write forces multiple layouts
elements.forEach(el => {
  const height = el.offsetHeight; // Read (forces layout)
  el.style.height = height + 10 + 'px'; // Write
});

// Good: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // All reads

elements.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px'; // All writes
});

Use CSS content-visibility for Long Pages

/* Skip rendering off-screen sections */
.section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated height */
}

Virtualize Long Lists

For lists with 100+ items, render only visible items:

  • React: react-window or react-virtualized
  • Vue: vue-virtual-scroller
  • Vanilla JS: virtual-scroller web component

Fix #5: Framework-Specific Optimizations

React

// Use React 18's concurrent features
import { useTransition, useDeferredValue } from 'react';

function SearchResults({ query }) {
  // Mark state updates as non-urgent
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);
  
  const handleSearch = (value) => {
    // Urgent: Update input immediately
    setQuery(value);
    
    // Non-urgent: Defer results update
    startTransition(() => {
      setResults(search(value));
    });
  };
  
  return (
    <div className={isPending ? 'opacity-50' : ''}>
      {results.map(r => <Result key={r.id} {...r} />)}
    </div>
  );
}

Vue

<!-- Use v-memo for expensive lists -->
<template>
  <div 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.selected]"
  >
    <ExpensiveComponent :item="item" />
  </div>
</template>

<!-- Use defineAsyncComponent for lazy loading -->
<script setup>
import { defineAsyncComponent } from 'vue';

const HeavyComponent = defineAsyncComponent(() =>
  import('./HeavyComponent.vue')
);
</script>

General Framework Tips

  • Avoid re-renders on every keystroke: Debounce input handlers
  • Memoize expensive computations: useMemo, computed properties
  • Lazy load routes/components: Don't load what users won't see
  • Use production builds: Dev builds have extra overhead
  • Consider Islands Architecture: Astro, Fresh, Qwik partial hydration

Monitoring INP in Production

Lab testing can't catch all INP issues. Real users have different devices, networks, and interaction patterns. Set up Real User Monitoring (RUM):

// Complete INP monitoring setup
import {onINP} from 'web-vitals';

onINP((metric) => {
  // Get interaction details
  const entry = metric.entries[0];
  const interactionTarget = entry?.target?.tagName;
  const interactionType = entry?.name; // 'click', 'keydown', etc.
  
  // Send to your analytics
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify({
      metric: 'INP',
      value: metric.value,
      rating: metric.rating,
      url: window.location.href,
      interactionTarget,
      interactionType,
      // Include device info
      deviceMemory: navigator.deviceMemory,
      connection: navigator.connection?.effectiveType
    }),
    keepalive: true // Ensure it sends even on page exit
  });
});

🎯 Monitor with Rankture

Rankture's Core Web Vitals monitoring automatically tracks INP along with LCP and CLS. Get alerts when your INP regresses and see which pages need attention. Learn more about automated CWV monitoring →

Frequently Asked Questions

What is a good INP score?

A good INP (Interaction to Next Paint) score is 200 milliseconds or less. Google considers 200-500ms as "needs improvement" and anything over 500ms as "poor". To pass Core Web Vitals, 75% of your page visits must have INP under 200ms. Unlike LCP which measures loading, INP measures how responsive your site feels during the entire user session.

What replaced FID in Core Web Vitals?

INP (Interaction to Next Paint) officially replaced FID (First Input Delay) as a Core Web Vital in March 2024. While FID only measured the first interaction's input delay, INP measures ALL interactions throughout a page visit and reports the worst one (at the 98th percentile). This makes INP a much more comprehensive measure of page interactivity.

Why is my INP score so bad when FID was fine?

FID only measured the delay before your first interaction was processed. INP measures every click, tap, and keypress throughout your session, then reports near the worst one. A page might handle the first click fine but struggle with later interactions after JavaScript loads. Heavy frameworks (React, Vue), third-party scripts, and complex event handlers often cause poor INP despite good FID scores.

Does INP affect SEO rankings?

Yes, INP is now a Core Web Vitals ranking factor as of March 2024. Google uses real user INP data (from Chrome UX Report) as part of the page experience signals. Sites with better INP scores have a ranking advantage, especially when competing against similar-quality content. Poor INP also hurts user experience and conversion rates.

How do I measure INP on my site?

Use these tools to measure INP: 1) PageSpeed Insights shows both lab and field INP data, 2) Chrome DevTools Performance panel with "Interactions" track enabled, 3) Web Vitals Chrome extension for real-time monitoring, 4) Google Search Console Core Web Vitals report for site-wide field data, 5) web-vitals JavaScript library for custom Real User Monitoring (RUM).

What causes bad INP scores?

Common INP killers include: 1) Long JavaScript tasks blocking the main thread, 2) Heavy frameworks with expensive re-renders (React, Vue, Angular), 3) Third-party scripts (analytics, ads, chat widgets), 4) Synchronous localStorage/sessionStorage access, 5) Large DOM trees causing slow updates, 6) Unoptimized event handlers with excessive computation, 7) Layout thrashing from reading and writing DOM in loops.

How long does it take to see INP improvements in Search Console?

Google Search Console uses Chrome UX Report data, which is collected over a 28-day rolling window. After deploying INP fixes, you'll see immediate improvements in lab tools (PageSpeed Insights, Lighthouse). But field data in Search Console takes 28+ days to fully reflect changes. Plan for 1-2 months between fixes and seeing ranking impact.

Can I pass Core Web Vitals with bad INP?

No. To pass Core Web Vitals assessment, you must meet "good" thresholds for ALL three metrics: LCP ≤ 2.5s, INP ≤ 200ms, AND CLS ≤ 0.1. Failing any single metric means failing the overall assessment. Since INP replaced FID in March 2024, many sites that previously passed now fail due to poor interactivity.

Related Core Web Vitals Guides

Check Your INP Score Now

See how your site performs on all Core Web Vitals including INP. Get actionable recommendations to pass Google's thresholds.

Run Free Audit →