Skip to Content
InteractionInteractions

Interactions

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

Tracks all user interactions in real-time to help debug and improve Interaction to Next Paint (INP) (opens in a new tab). INP measures responsiveness by tracking the latency of all interactions during a page visit. This snippet breaks down each interaction into three phases to identify bottlenecks. Based on the Web Vitals Chrome Extension (opens in a new tab).

Why this matters

INP is a Core Web Vital that directly affects user experience. Slow interactions make your site feel sluggish and frustrating. By identifying which phase causes delays (input delay, processing, or presentation), you can apply targeted optimizations to make your site feel more responsive.

INP rating thresholds

RatingDurationMeaning
Good≤ 200msFast, responsive interaction
Needs Improvement≤ 500msNoticeable delay
Poor> 500msFrustrating delay

INP sub-parts

Every interaction consists of three phases:

Sub-partWhat it measuresCommon causes of delays
Input DelayTime from user input to processing startLong tasks blocking main thread
Processing TimeEvent handler executionComplex JavaScript, slow handlers
Presentation DelayRendering after processingLarge DOM updates, layout thrashing
Sequence of an interaction: the user input waits for the main thread (input delay), the event handler runs (processing time), then the renderer lays out and paints the visual feedback (presentation delay); the three add up to the totalSteps: User to Main Thread: Click/Tap/Keypress; Note: Input Delay (waiting for main thread); Main Thread to Event Handler: Start processing; Note: Processing Time (event handler execution); Event Handler to Event Handler: Run JavaScript; Event Handler to Event Handler: Update state; Event Handler to Renderer: Request render; Note: Presentation Delay (render + paint); Renderer to Renderer: Layout calculation; Renderer to Renderer: Paint; Renderer to User: Visual feedback; Note: Total = Input Delay + Processing Time + Presentation Delay.Click/Tap/KeypressInput Delay(waiting for main thread)Start processingProcessing Time(event handler execution)Run JavaScriptUpdate stateRequest renderPresentation Delay(render + paint)Layout calculationPaintVisual feedbackTotal = Input Delay + Processing Time + Presentation DelayUserMain ThreadEvent HandlerRenderer
The three interaction phases in order: user input, input delay, processing time, presentation delay, then visual feedbackConnections: User Input to Input Delay; Input Delay to Processing Time; Processing Time to Presentation Delay; Presentation Delay to Visual Feedback.UserInputInput DelayWaiting for mainthreadProcessing TimeEvent handlerexecutionPresentation DelayRender + PaintVisualFeedback
The three interaction phases in order: user input, input delay, processing time, presentation delay, then visual feedbackConnections: User Input to Input Delay; Input Delay to Processing Time; Processing Time to Presentation Delay; Presentation Delay to Visual Feedback.UserInputInput DelayWaiting for mainthreadProcessing TimeEvent handlerexecutionPresentationDelayRender + PaintVisualFeedback

Tip: The sub-part with the longest duration is usually where to focus optimization efforts.

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Interaction Tracking
// https://webperf-snippets.net

(() => {
  const formatMs = (ms) => `${Math.round(ms)}ms`;

  // INP thresholds
  const valueToRating = (score) =>
    score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";

  const RATING_COLORS = {
    good: "#0CCE6A",
    "needs-improvement": "#FFA400",
    poor: "#FF4E42",
  };

  const RATING_ICONS = {
    good: "🟢",
    "needs-improvement": "🟡",
    poor: "🔴",
  };

  // Track all interactions for summary
  const allInteractions = [];

  const observer = new PerformanceObserver((list) => {
    const interactions = {};

    for (const entry of list
      .getEntries()
      .filter((entry) => entry.interactionId)) {
      interactions[entry.interactionId] = interactions[entry.interactionId] || [];
      interactions[entry.interactionId].push(entry);
    }

    for (const interaction of Object.values(interactions)) {
      const entry = interaction.reduce((prev, curr) =>
        prev.duration >= curr.duration ? prev : curr
      );

      const value = entry.duration;
      const rating = valueToRating(value);
      const icon = RATING_ICONS[rating];
      const color = RATING_COLORS[rating];

      // Store for summary
      allInteractions.push({
        duration: value,
        rating,
        target: entry.target,
        type: entry.name,
      });

      // Calculate sub-parts
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const presentationDelay = Math.max(
        4,
        entry.startTime + entry.duration - entry.processingEnd
      );
      const total = inputDelay + processingTime + presentationDelay;

      // Find longest sub-part
      const subParts = [
        { name: "Input Delay", value: inputDelay },
        { name: "Processing Time", value: processingTime },
        { name: "Presentation Delay", value: presentationDelay },
      ];
      const longest = subParts.reduce((a, b) => (a.value > b.value ? a : b));

      console.groupCollapsed(
        `%c${icon} Interaction: ${formatMs(value)} (${rating})`,
        `font-weight: bold; color: ${color};`
      );

      // Target info
      console.log("%cTarget:", "font-weight: bold;", entry.target);
      console.log(`   Event type: ${entry.name}`);

      // Sub-parts breakdown
      console.log("");
      console.log("%cSub-parts breakdown:", "font-weight: bold;");

      const tableData = subParts.map((part) => {
        const percent = ((part.value / total) * 100).toFixed(0);
        const isLongest = part.name === longest.name;
        return {
          "Sub-part": isLongest ? `⚠️ ${part.name}` : part.name,
          Duration: formatMs(part.value),
          "%": `${percent}%`,
        };
      });

      console.table(tableData);

      // Visual bar
      const barWidth = 40;
      const inputBar = "█".repeat(Math.round((inputDelay / total) * barWidth));
      const procBar = "▓".repeat(Math.round((processingTime / total) * barWidth));
      const presBar = "░".repeat(Math.round((presentationDelay / total) * barWidth));
      console.log(`   ${inputBar}${procBar}${presBar}`);
      console.log("   █ Input  ▓ Processing  ░ Presentation");

      // Recommendation if slow
      if (rating !== "good") {
        console.log("");
        console.log("%c💡 Optimization hint:", "font-weight: bold; color: #3b82f6;");
        if (longest.name === "Input Delay") {
          console.log("   Break up long tasks blocking the main thread");
          console.log("   Use requestIdleCallback or setTimeout for non-critical work");
        } else if (longest.name === "Processing Time") {
          console.log("   Optimize event handlers, reduce JavaScript complexity");
          console.log("   Consider debouncing or using web workers");
        } else {
          console.log("   Reduce DOM size or complexity of updates");
          console.log("   Avoid forced synchronous layouts");
        }
      }

      console.groupEnd();
    }
  });

  observer.observe({
    type: "event",
    durationThreshold: 0,
    buffered: true,
  });

  // Summary function
  window.getInteractionSummary = () => {
    if (allInteractions.length === 0) {
      console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
      console.log("   Interact with the page (click, type, etc.) and call this again.");
      return { script: "Interactions", status: "error", error: "No interactions recorded yet", count: 0 };
    }

    console.group("%c📊 Interaction Summary", "font-weight: bold; font-size: 14px;");

    const durations = allInteractions.map((i) => i.duration);
    const worst = Math.max(...durations);
    const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
    const p75 = durations.sort((a, b) => a - b)[Math.floor(durations.length * 0.75)];

    const worstRating = valueToRating(worst);
    const p75Rating = valueToRating(p75);

    console.log("");
    console.log("%cStatistics:", "font-weight: bold;");
    console.log(`   Total interactions: ${allInteractions.length}`);
    console.log(
      `   Worst: %c${formatMs(worst)} (${worstRating})`,
      `color: ${RATING_COLORS[worstRating]};`
    );
    console.log(
      `   P75 (INP): %c${formatMs(p75)} (${p75Rating})`,
      `color: ${RATING_COLORS[p75Rating]};`
    );
    console.log(`   Average: ${formatMs(avg)}`);

    // Rating breakdown
    const good = allInteractions.filter((i) => i.rating === "good").length;
    const needsImprovement = allInteractions.filter(
      (i) => i.rating === "needs-improvement"
    ).length;
    const poor = allInteractions.filter((i) => i.rating === "poor").length;

    console.log("");
    console.log("%cBy rating:", "font-weight: bold;");
    console.log(`   🟢 Good (≤200ms): ${good}`);
    console.log(`   🟡 Needs Improvement (≤500ms): ${needsImprovement}`);
    console.log(`   🔴 Poor (>500ms): ${poor}`);

    // Slow interactions
    const slowInteractions = allInteractions.filter((i) => i.rating !== "good");
    if (slowInteractions.length > 0) {
      console.log("");
      console.log("%c⚠️ Slow interactions:", "font-weight: bold; color: #ef4444;");
      slowInteractions.forEach((i, idx) => {
        const icon = RATING_ICONS[i.rating];
        console.log(`   ${idx + 1}. ${icon} ${i.type} - ${formatMs(i.duration)}`, i.target);
      });
    }

    const byRating = {
      good: allInteractions.filter((i) => i.rating === "good").length,
      "needs-improvement": allInteractions.filter((i) => i.rating === "needs-improvement").length,
      poor: allInteractions.filter((i) => i.rating === "poor").length,
    };
    console.groupEnd();
    return {
      script: "Interactions",
      status: "ok",
      count: allInteractions.length,
      details: {
        totalInteractions: allInteractions.length,
        worstMs: Math.round(worst),
        avgMs: Math.round(avg),
        p75Ms: Math.round(p75),
        byRating,
      },
      // Slowest first, at most 50
      items: [...allInteractions]
        .sort((a, b) => b.duration - a.duration)
        .slice(0, 50)
        .map((i) => ({
          type: i.type,
          durationMs: Math.round(i.duration),
          rating: i.rating,
        })),
      issues: [...slowInteractions]
        .sort((a, b) => b.duration - a.duration)
        .slice(0, 10)
        .map((i) => ({
          severity: i.rating === "poor" ? "error" : "warning",
          message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
        })),
    };
  };

  console.log("%c👆 Interaction Tracking Active", "font-weight: bold; font-size: 14px;");
  console.log("   Interact with the page to see interaction details.");
  console.log("   Call %cgetInteractionSummary()%c for a summary.", "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", "");

  return {
    script: "Interactions",
    status: "tracking",
    message: "Tracking interactions. Interact with the page then call getInteractionSummary() for results.",
    getDataFn: "getInteractionSummary",
  };
})();

Understanding the results

Real-time output

Each interaction logs:

  • Duration with rating indicator (//)
  • Target element
  • Event type (click, keydown, etc.)
  • Sub-parts breakdown with percentages
  • Visual bar showing time distribution
  • Optimization hints for slow interactions

Summary function

Call getInteractionSummary() in the console to see:

MetricDescription
Total interactionsNumber of tracked interactions
WorstLongest interaction duration
P75 (INP)75th percentile - this is your INP score
AverageMean duration across all interactions
By ratingCount of good/needs-improvement/poor

Optimizing each sub-part

Sub-partProblemSolutions
Input DelayLong tasks block main threadBreak up long tasks, yield to main thread, use scheduler.yield()
Processing TimeSlow event handlersOptimize handlers, debounce, use web workers
Presentation DelayExpensive renderingReduce DOM size, avoid layout thrashing, use content-visibility

Optimization decision tree

Decision tree for INP above 200 ms: find the longest sub-part, then follow input delay to breaking up long tasks, processing time to optimizing handlers, or presentation delay to reducing DOM updatesConnections: INP > 200ms? to Identify longest sub-part; Identify longest sub-part to Presentation Delay is longest?; Identify longest sub-part to Processing Time is longest?; Identify longest sub-part to Input Delay is longest?; Input Delay is longest? to Break up long tasks Use scheduler.yield() Defer non-critical work Use requestIdleCallback (Yes); Processing Time is longest? to Optimize event handlers Debounce rapid events Move work to Web Workers Reduce JavaScript complexity (Yes); Presentation Delay is longest? to Reduce DOM updates Avoid layout thrashing Use content-visibility Batch DOM changes (Yes).INP > 200ms?Identify longestsub-partInput Delayis longest?Processing Timeis longest?Presentation Delayis longest?Break up long tasksUse scheduler.yield()Defer non-critical workUse requestIdleCallbackOptimize event handlersDebounce rapid eventsMove work to Web WorkersReduce JavaScript complexityReduce DOM updatesAvoid layout thrashingUse content-visibilityBatch DOM changesYesYesYes
Decision tree for INP above 200 ms: find the longest sub-part, then follow input delay to breaking up long tasks, processing time to optimizing handlers, or presentation delay to reducing DOM updatesConnections: INP > 200ms? to Identify longest sub-part; Identify longest sub-part to Presentation Delay is longest?; Identify longest sub-part to Processing Time is longest?; Identify longest sub-part to Input Delay is longest?; Input Delay is longest? to Break up long tasks Use scheduler.yield() Defer non-critical work Use requestIdleCallback (Yes); Processing Time is longest? to Optimize event handlers Debounce rapid events Move work to Web Workers Reduce JavaScript complexity (Yes); Presentation Delay is longest? to Reduce DOM updates Avoid layout thrashing Use content-visibility Batch DOM changes (Yes).INP > 200ms?Identifylongestsub-partInput Delayis longest?ProcessingTimeis longest?PresentationDelayis longest?Break up longtasksUsescheduler.yield()Defer non-criticalworkUserequestIdleCallbackOptimize eventhandlersDebounce rapideventsMove work toWeb WorkersReduceJavaScriptcomplexityReduce DOMupdatesAvoid layoutthrashingUsecontent-visibilityBatch DOMchangesYesYesYes

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
PerformanceObserver (opens in a new tab) 52 79 57 11
All of the above 96 96 144 26.2

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

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb