Core Web Vitals
INP

Interaction to Next Paint (INP)

Overview

Tracks Interaction to Next Paint (opens in a new tab), a Core Web Vital that measures responsiveness. INP evaluates how quickly a page responds to user interactions throughout the entire page visit, replacing First Input Delay (FID) as a Core Web Vital in March 2024.

Why this matters:

INP directly measures user frustration. When users click a button and nothing happens for seconds, they abandon tasks. Unlike FID which only measured first interaction, INP tracks ALL interactions, making it a more comprehensive responsiveness metric and a key ranking factor for Google Search.

INP Rating Thresholds:

RatingTimeMeaning
🟢 Good≤ 200msResponsive, feels instant
🟡 Needs Improvement≤ 500msNoticeable delay
🔴 Poor> 500msSlow, frustrating experience

Need to debug? Use Long Animation Frames to identify which scripts are blocking interactions.

INP Measurement Approach:

INP vs FID Comparison:

AspectFID (Old)INP (New)
Interactions measuredOnly first interactionAll interactions
What's measuredInput delay onlyFull duration to paint
CalculationSingle value98th percentile
Reflects real UXPartialComplete
StatusDeprecated (March 2024)Active Core Web Vital

Snippet

// INP (Interaction to Next Paint) Tracking
// https://webperf-snippets.nucliweb.net
 
(() => {
  const interactions = [];
  let inpValue = 0;
  let inpEntry = null;
 
  const valueToRating = (ms) =>
    ms <= 200 ? "good" : ms <= 500 ? "needs-improvement" : "poor";
 
  const RATING = {
    good: { icon: "🟢", color: "#0CCE6A" },
    "needs-improvement": { icon: "🟡", color: "#FFA400" },
    poor: { icon: "🔴", color: "#FF4E42" },
  };
 
  const formatMs = (ms) => `${Math.round(ms)}ms`;
 
  // Calculate INP (98th percentile of all interactions)
  const calculateINP = () => {
    if (interactions.length === 0) return { value: 0, entry: null };
 
    // Sort by duration
    const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
 
    // Get 98th percentile (or worst if < 50 interactions)
    const index = interactions.length < 50
      ? 0
      : Math.floor(interactions.length * 0.02);
 
    return {
      value: sorted[index].duration,
      entry: sorted[index],
    };
  };
 
  // Format interaction name
  const getInteractionName = (entry) => {
    const target = entry.target;
    if (!target) return entry.name;
 
    let selector = target.tagName.toLowerCase();
    if (target.id) selector += `#${target.id}`;
    else if (target.className && typeof target.className === "string") {
      const classes = target.className.trim().split(/\s+/).slice(0, 2).join(".");
      if (classes) selector += `.${classes}`;
    }
 
    return `${entry.name}${selector}`;
  };
 
  // Get phase breakdown (requires LoAF support)
  const getPhaseBreakdown = (entry) => {
    const phases = {
      inputDelay: 0,
      processingTime: 0,
      presentationDelay: 0,
    };
 
    if (entry.processingStart && entry.processingEnd) {
      phases.inputDelay = entry.processingStart - entry.startTime;
      phases.processingTime = entry.processingEnd - entry.processingStart;
      phases.presentationDelay = entry.duration - phases.inputDelay - phases.processingTime;
    }
 
    return phases;
  };
 
  // Observer for interactions
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Only track interactions with interactionId (meaningful interactions)
      if (!entry.interactionId) continue;
 
      // Avoid duplicate entries for the same interaction
      const existing = interactions.find(
        (i) => i.interactionId === entry.interactionId
      );
 
      if (!existing || entry.duration > existing.duration) {
        // Remove old entry if exists
        if (existing) {
          const idx = interactions.indexOf(existing);
          interactions.splice(idx, 1);
        }
 
        interactions.push({
          name: entry.name,
          duration: entry.duration,
          startTime: entry.startTime,
          interactionId: entry.interactionId,
          target: entry.target,
          processingStart: entry.processingStart,
          processingEnd: entry.processingEnd,
          formattedName: getInteractionName(entry),
          phases: getPhaseBreakdown(entry),
          entry,
        });
      }
 
      // Recalculate INP
      const result = calculateINP();
      inpValue = result.value;
      inpEntry = result.entry;
    }
  });
 
  // Observe event timing
  observer.observe({
    type: "event",
    buffered: true,
    durationThreshold: 16, // Only interactions > 16ms (1 frame)
  });
 
  // Log INP summary
  const logINP = () => {
    const rating = valueToRating(inpValue);
    const { icon, color } = RATING[rating];
 
    console.group(
      `%cINP: ${icon} ${formatMs(inpValue)} (${rating})`,
      `color: ${color}; font-weight: bold; font-size: 14px;`
    );
 
    console.log("");
    console.log(`%c📊 Analysis:`, "font-weight: bold;");
    console.log(`   Total interactions tracked: ${interactions.length}`);
    console.log(`   INP (98th percentile): ${formatMs(inpValue)}`);
 
    if (inpEntry) {
      console.log("");
      console.log(`%c🎯 Worst Interaction (INP):`, "font-weight: bold; color: ${color};");
      console.log(`   Event: ${inpEntry.formattedName}`);
      console.log(`   Duration: ${formatMs(inpEntry.duration)}`);
 
      // Element attribution
      if (inpEntry.target) {
        console.log(`   Target Element:`, inpEntry.target);
 
        // Get element path for better context
        const getElementPath = (el) => {
          if (!el) return "";
          const parts = [];
          let current = el;
          while (current && current !== document.body && parts.length < 5) {
            let selector = current.tagName.toLowerCase();
            if (current.id) selector += `#${current.id}`;
            else if (current.className && typeof current.className === "string") {
              const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
              if (classes) selector += `.${classes}`;
            }
            parts.unshift(selector);
            current = current.parentElement;
          }
          return parts.join(" > ");
        };
 
        const path = getElementPath(inpEntry.target);
        if (path) {
          console.log(`   Element Path: ${path}`);
        }
      }
 
      // Phase breakdown
      const phases = inpEntry.phases;
      if (phases.inputDelay > 0) {
        console.log("");
        console.log(`%c⏱️ Phase Breakdown:`, "font-weight: bold;");
        console.log(`   Input Delay: ${formatMs(phases.inputDelay)}`);
        console.log(`   Processing Time: ${formatMs(phases.processingTime)}`);
        console.log(`   Presentation Delay: ${formatMs(phases.presentationDelay)}`);
 
        // Visual bar
        const total = inpEntry.duration;
        const barWidth = 40;
        const inputBar = "▓".repeat(Math.round((phases.inputDelay / total) * barWidth));
        const processBar = "█".repeat(Math.round((phases.processingTime / total) * barWidth));
        const presentBar = "░".repeat(Math.round((phases.presentationDelay / total) * barWidth));
        console.log(`   ${inputBar}${processBar}${presentBar}`);
        console.log("   ▓ Input  █ Processing  ░ Presentation");
      }
 
      // Recommendations based on phases
      if (inpValue > 200 && phases.inputDelay > 0) {
        console.log("");
        console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;");
 
        if (phases.inputDelay > 100) {
          console.log("   • High input delay - Break up long tasks before interaction");
        }
        if (phases.processingTime > 200) {
          console.log("   • Long processing time - Optimize event handlers");
          console.log("   • Consider debouncing, use requestIdleCallback for non-critical work");
        }
        if (phases.presentationDelay > 100) {
          console.log("   • High presentation delay - Reduce render complexity");
          console.log("   • Batch DOM updates, use content-visibility");
        }
      }
    }
 
    // Slow interactions breakdown
    const slowInteractions = interactions
      .filter((i) => i.duration > 200)
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 10);
 
    if (slowInteractions.length > 0) {
      console.log("");
      console.log(`%c🐌 Slow Interactions (> 200ms):`, "color: #ef4444; font-weight: bold;");
      console.table(
        slowInteractions.map((i) => ({
          Event: i.formattedName,
          "Duration (ms)": Math.round(i.duration),
          "Start Time (ms)": Math.round(i.startTime),
        }))
      );
 
      // Show element attribution for top 3
      console.log("");
      console.log(`%c🎯 Element Attribution (top 3):`, "font-weight: bold;");
      slowInteractions.slice(0, 3).forEach((interaction, idx) => {
        console.log(`   ${idx + 1}. ${interaction.formattedName} (${Math.round(interaction.duration)}ms)`);
        if (interaction.target) {
          console.log(`      Element:`, interaction.target);
        } else {
          console.log(`      Element: (no target available)`);
        }
      });
    }
 
    // Interaction types breakdown
    const byType = {};
    interactions.forEach((i) => {
      const type = i.name;
      if (!byType[type]) {
        byType[type] = { count: 0, totalDuration: 0, maxDuration: 0 };
      }
      byType[type].count++;
      byType[type].totalDuration += i.duration;
      byType[type].maxDuration = Math.max(byType[type].maxDuration, i.duration);
    });
 
    if (Object.keys(byType).length > 0) {
      console.log("");
      console.log(`%c📋 By Interaction Type:`, "font-weight: bold;");
      console.table(
        Object.entries(byType).map(([type, stats]) => ({
          Type: type,
          Count: stats.count,
          "Avg (ms)": Math.round(stats.totalDuration / stats.count),
          "Max (ms)": Math.round(stats.maxDuration),
        }))
      );
    }
 
    // General recommendations if no phases available
    if (inpValue > 200 && (!inpEntry || !inpEntry.phases || inpEntry.phases.inputDelay === 0)) {
      console.log("");
      console.log("%c💡 Recommendations:", "color: #3b82f6; font-weight: bold;");
      console.log("   • Break up long tasks using scheduler.yield() or setTimeout");
      console.log("   • Optimize event handlers - reduce computation time");
      console.log("   • Consider debouncing for frequent events");
      console.log("   • Move heavy work to Web Workers");
      console.log("   • Use requestIdleCallback for non-critical work");
 
      console.log("");
      console.log("   Run getINPDetails() for full interaction list");
      console.log("   Use Long Animation Frames snippet to identify blocking scripts");
    }
 
    console.groupEnd();
  };
 
  // Expose function to check INP anytime
  window.getINP = () => {
    const result = calculateINP();
    inpValue = result.value;
    inpEntry = result.entry;
    logINP();
    return { value: inpValue, rating: valueToRating(inpValue) };
  };
 
  // Expose function to get all interactions
  window.getINPDetails = () => {
    console.group("%c📊 All Interactions Detail", "font-weight: bold; font-size: 14px;");
 
    if (interactions.length === 0) {
      console.log("   No interactions recorded yet.");
      console.groupEnd();
      return [];
    }
 
    const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
 
    console.log("");
    console.log("%cInteraction Summary:", "font-weight: bold;");
    console.table(
      sorted.map((i, idx) => ({
        "#": idx + 1,
        Event: i.formattedName,
        "Duration (ms)": Math.round(i.duration),
        "Start (ms)": Math.round(i.startTime),
        "Input Delay": Math.round(i.phases.inputDelay),
        Processing: Math.round(i.phases.processingTime),
        Presentation: Math.round(i.phases.presentationDelay),
      }))
    );
 
    // Show element attribution for all interactions
    console.log("");
    console.log("%c🎯 Element Attribution:", "font-weight: bold;");
 
    const maxToShow = Math.min(sorted.length, 15); // Show up to 15
    sorted.slice(0, maxToShow).forEach((interaction, idx) => {
      const phases = interaction.phases;
      const hasPhases = phases.inputDelay > 0;
 
      console.group(
        `${idx + 1}. ${interaction.formattedName} - ${Math.round(interaction.duration)}ms`
      );
 
      if (interaction.target) {
        console.log("Element:", interaction.target);
 
        // Get element path for better identification
        const getPath = (el) => {
          if (!el) return "";
          const parts = [];
          let current = el;
          while (current && current !== document.body && parts.length < 5) {
            let selector = current.tagName.toLowerCase();
            if (current.id) selector += `#${current.id}`;
            else if (current.className && typeof current.className === "string") {
              const classes = current.className.trim().split(/\s+/).slice(0, 2).join(".");
              if (classes) selector += `.${classes}`;
            }
            parts.unshift(selector);
            current = current.parentElement;
          }
          return parts.join(" > ");
        };
 
        const path = getPath(interaction.target);
        if (path) {
          console.log("Path:", path);
        }
      } else {
        console.log("Element: (no target available)");
      }
 
      if (hasPhases) {
        console.log(
          `Phases: Input ${Math.round(phases.inputDelay)}ms | ` +
          `Processing ${Math.round(phases.processingTime)}ms | ` +
          `Presentation ${Math.round(phases.presentationDelay)}ms`
        );
      }
 
      console.groupEnd();
    });
 
    if (sorted.length > maxToShow) {
      console.log(`   ... and ${sorted.length - maxToShow} more interactions`);
    }
 
    console.groupEnd();
    return sorted;
  };
 
  // Log on page hide (final INP)
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
      observer.takeRecords();
      const result = calculateINP();
      inpValue = result.value;
      inpEntry = result.entry;
 
      console.log("%c📊 Final INP (on page hide):", "font-weight: bold;");
      logINP();
    }
  });
 
  console.log("%c⚡ INP Tracking Active", "font-weight: bold; font-size: 14px;");
  console.log("   Interactions with duration > 16ms will be tracked.");
  console.log(
    "   Call %cgetINP()%c to see current INP value.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );
  console.log(
    "   Call %cgetINPDetails()%c for full interaction list.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );
})();

Understanding INP

What makes a good INP:

INP measures the latency of ALL user interactions during the page's lifetime. Unlike FID (which only measured first input delay), INP captures:

  • All clicks, taps, and key presses
  • The full duration from input to visual update
  • The 98th percentile (worst case for most users)

INP has three phases:

PhaseDescriptionCommon causes of delay
Input DelayTime from user action to handler startLong tasks blocking main thread
Processing TimeEvent handler executionHeavy computation, DOM manipulation
Presentation DelayTime to render updateLarge render tree, forced layouts

INP updates throughout the page lifetime:

  • INP is calculated from all interactions
  • Takes the 98th percentile duration
  • For pages with < 50 interactions, uses the worst interaction
  • Final INP reported when page is hidden

Common Causes of Poor INP

CauseDetectionSolution
Long tasksHigh input delayBreak tasks with scheduler.yield(), move to Workers
Heavy event handlersHigh processing timeDebounce, defer non-critical work
Forced layoutsHigh presentation delayBatch DOM reads/writes, use content-visibility
Third-party scriptsScripts blocking interactionsDefer scripts, use facades for embeds
Large DOMSlow renderingVirtualize lists, lazy load components

Interpreting Results

Output Overview:

The snippet provides comprehensive INP analysis including:

  • INP Value: 98th percentile of all interactions with color-coded rating
  • Worst Interaction: The interaction that defines your INP score
  • Element Attribution: Target element and DOM path (e.g., div.container > button#submit)
  • Phase Breakdown: Input delay, processing time, and presentation delay with visual bar
  • Slow Interactions: Top 10 slowest interactions with their target elements
  • Interaction Types: Summary by event type (click, keydown, etc.)
  • Actionable Recommendations: Specific advice based on bottleneck phase

Phase Analysis:

If your INP is poor (> 500ms), check which phase dominates:

  • Input Delay > 50%: Main thread is blocked before handlers run

    • Solution: Break up long tasks, reduce JavaScript execution time
  • Processing Time > 50%: Event handlers are slow

    • Solution: Optimize handler logic, debounce events, use Web Workers
  • Presentation Delay > 50%: Rendering is slow

    • Solution: Reduce render complexity, batch updates, avoid forced layouts

Element Attribution:

The snippet identifies which specific DOM element caused each interaction:

🎯 Worst Interaction (INP):
   Event: click → button.btn.btn-primary
   Duration: 384ms
   Target Element: <button class="btn btn-primary">Submit</button>
   Element Path: div#form-container > form.checkout-form > button.btn.btn-primary

⏱️ Phase Breakdown:
   Input Delay: 45ms
   Processing Time: 298ms
   Presentation Delay: 41ms

This helps you:

  • Identify the culprit: Know exactly which button, input, or link is slow
  • Reproduce locally: Navigate to the element in the DOM inspector
  • Track patterns: See if all interactions on certain elements are slow

Interaction Types:

Different interaction types have different performance profiles:

TypeTypical DurationOptimization Focus
pointerdown16-32msUsually fast, check for prevent-default issues
click50-200msOptimize click handlers, avoid heavy DOM updates
keydown32-100msDebounce text input, defer validation
pointerup16-50msUsually fast unless triggering complex actions

Browser Support

BrowserINP SupportEvent Timing APIPhase Breakdown
Chrome 96+
Edge 96+
Firefox❌*Partial
Safari❌*

* Firefox and Safari don't support interactionId yet. The snippet will track events but won't group them into interactions correctly.

Troubleshooting

"No interactions recorded"

  • Cause: Page hasn't had meaningful interactions yet
  • Solution: Interact with the page (clicks, keypresses) then run getINP()

"All interactions show 0ms for phases"

  • Cause: Browser doesn't support detailed phase breakdown
  • Solution: Use Chrome 96+ for full support. The total duration is still accurate.

"INP keeps changing"

  • This is expected! INP is calculated from all interactions as a 98th percentile
  • It will update as more interactions occur
  • The final value is reported when the page is hidden

"Want to see real-time updates?"

// Log INP after each interaction
setInterval(() => {
  const result = getINP();
  // Check console
}, 2000);

Integration with RUM

Example integration with popular RUM tools:

// Google Analytics 4
window.getINP = () => {
  const result = calculateINP();
  gtag('event', 'web_vitals', {
    metric_name: 'INP',
    value: Math.round(result.value),
    rating: valueToRating(result.value),
  });
  return result;
};
 
// DataDog RUM
window.getINP = () => {
  const result = calculateINP();
  window.DD_RUM.addTiming('inp', result.value);
  return result;
};
 
// New Relic
window.getINP = () => {
  const result = calculateINP();
  window.newrelic.addPageAction('webVitals', {
    metric: 'INP',
    value: result.value,
  });
  return result;
};

Further Reading