Core Web Vitals
INP

Interaction to next paint (INP)

Chrome 96+ Edge 96+ Firefox 144+ Safari 26.2+ Details

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

How INP is measured: each interaction goes through input queue, event handlers, render and next paint, its total duration is recorded, and INP is the 98th percentile of all interactionsConnections: User Interaction to Input Queued; Input Queued to Event Handlers Execute (Input Delay); Event Handlers Execute to Browser Renders Update (Processing Time); Browser Renders Update to Next Paint Visible (Presentation Delay); Next Paint Visible to Measure Total Duration; Measure Total Duration to Record Interaction; Record Interaction to More Interactions?; More Interactions? to User Interaction (Yes); More Interactions? to Calculate INP p98 of all interactions (No).User InteractionInput QueuedEvent Handlers ExecuteBrowser Renders UpdateNext Paint VisibleMeasure Total DurationRecord InteractionMore Interactions?Calculate INPp98 of all interactionsInput DelayProcessing TimePresentation DelayYesNo

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

(() => {
  if (!PerformanceObserver.supportedEntryTypes?.includes("event")) {
    console.warn("⚠️ event entries are not supported in this browser.");
    return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
  }
  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);

    // Skip one interaction for every 50. Use the browser's total interaction count when
    // available: `interactions` only holds those above the duration threshold.
    const totalInteractions = performance.interactionCount || interactions.length;
    const index = Math.min(sorted.length - 1, Math.floor(totalInteractions / 50));

    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 from the Event Timing entry
  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 = Math.max(
        0,
        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();
    const rating = valueToRating(inpValue);
    const details = { totalInteractions: interactions.length };
    if (inpEntry) {
      details.worstEvent = inpEntry.formattedName;
      details.phases = {
        inputDelay: Math.round(inpEntry.phases.inputDelay),
        processingTime: Math.round(inpEntry.phases.processingTime),
        presentationDelay: Math.round(inpEntry.phases.presentationDelay),
      };
    }
    if (interactions.length === 0) {
      return {
        script: "INP",
        status: "error",
        error: "No interactions recorded yet. Interact with the page and call getINP() again.",
        getDataFn: "getINP",
        details,
      };
    }
    return {
      script: "INP",
      status: "ok",
      metric: "INP",
      value: Math.round(inpValue),
      unit: "ms",
      rating,
      thresholds: { good: 200, needsImprovement: 500 },
      details,
    };
  };

  // 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;",
    ""
  );

  return {
    script: "INP",
    status: "tracking",
    message: "INP tracking active. Interact with the page then call getINP() for results.",
    getDataFn: "getINP",
  };
})();

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
Switch scenarios to see which phase dominates the interaction, and select a phase to highlight it. Open demo in a new tab ↗

INP updates throughout the page lifetime

  • INP is calculated from all interactions
  • Takes the 98th percentile duration
  • Skips one interaction for every 50 the page has received (performance.interactionCount), so pages with fewer than 50 interactions use the worst one
  • 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

FeatureChromeEdgeFirefoxSafari
PerformanceEventTiming (opens in a new tab) 76 79 89 26.2
PerformanceEventTiming.interactionId (opens in a new tab) 96 96 144 26.2
All of the above 96 96 144 26.2

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

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