# Interactions

> Source: <https://webperf-snippets.net/Interaction/Interactions>

Tracks all user interactions in real-time to help debug and improve [Interaction to Next Paint (INP)](https://web.dev/articles/inp). 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](https://github.com/GoogleChrome/web-vitals-extension).

### 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

| Rating               | Duration | Meaning                      |
| -------------------- | -------- | ---------------------------- |
| 🟢 Good              | ≤ 200ms  | Fast, responsive interaction |
| 🟡 Needs Improvement | ≤ 500ms  | Noticeable delay             |
| 🔴 Poor              | > 500ms  | Frustrating delay            |

### INP sub-parts

Every interaction consists of three phases:

| Sub-part               | What it measures                         | Common causes of delays             |
| ---------------------- | ---------------------------------------- | ----------------------------------- |
| **Input Delay**        | Time from user input to processing start | Long tasks blocking main thread     |
| **Processing Time**    | Event handler execution                  | Complex JavaScript, slow handlers   |
| **Presentation Delay** | Rendering after processing               | Large DOM updates, layout thrashing |

Diagram: 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 total

Diagram: The three interaction phases in order: user input, input delay, processing time, presentation delay, then visual feedback

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

### Snippet

```js
// 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:

| Metric             | Description                              |
| ------------------ | ---------------------------------------- |
| Total interactions | Number of tracked interactions           |
| Worst              | Longest interaction duration             |
| P75 (INP)          | 75th percentile - this is your INP score |
| Average            | Mean duration across all interactions    |
| By rating          | Count of good/needs-improvement/poor     |

### Optimizing each sub-part

| Sub-part               | Problem                      | Solutions                                                          |
| ---------------------- | ---------------------------- | ------------------------------------------------------------------ |
| **Input Delay**        | Long tasks block main thread | Break up long tasks, yield to main thread, use `scheduler.yield()` |
| **Processing Time**    | Slow event handlers          | Optimize handlers, debounce, use web workers                       |
| **Presentation Delay** | Expensive rendering          | Reduce DOM size, avoid layout thrashing, use `content-visibility`  |

### Optimization decision tree

Diagram: 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 updates

### Browser support

| Feature                                                                                                                 | Chrome | Edge | Firefox | Safari |
| ----------------------------------------------------------------------------------------------------------------------- | :----: | :--: | :-----: | :----: |
| [PerformanceEventTiming](https://developer.mozilla.org/docs/Web/API/PerformanceEventTiming)                             |  ✅ 76  | ✅ 79 |   ✅ 89  | ✅ 26.2 |
| [PerformanceEventTiming.interactionId](https://developer.mozilla.org/docs/Web/API/PerformanceEventTiming/interactionId) |  ✅ 96  | ✅ 96 |  ✅ 144  | ✅ 26.2 |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver)                                   |  ✅ 52  | ✅ 79 |   ✅ 57  |  ✅ 11  |
| All of the above                                                                                                        |  ✅ 96  | ✅ 96 |  ✅ 144  | ✅ 26.2 |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Interaction to Next Paint (INP)](https://web.dev/articles/inp) | web.dev
- [Optimize INP](https://web.dev/articles/optimize-inp) | web.dev
- [Find slow interactions](https://web.dev/articles/find-slow-interactions-in-the-field) | web.dev
- [Web Vitals Extension (archived)](https://github.com/GoogleChrome/web-vitals-extension) | GitHub
