# Input latency breakdown

> Source: <https://webperf-snippets.net/Interaction/Input-Latency-Breakdown>

Aggregates interaction latency by event type to reveal which phase causes slowness across all interactions with the page. While [Interactions](https://webperf-snippets.net/Interaction/Interactions.md) shows a per-interaction breakdown in real time, this snippet collects data over time and answers a different question: **is click systematically slower than keypress? Is the bottleneck always input delay, or does it vary by event?**

### Why this matters

INP measures the worst interaction, but understanding the pattern across many interactions is more actionable. A click with high input delay has a different fix than a keypress with slow processing time. Grouping by event type surfaces systematic problems instead of outliers.

### The three phases of every interaction

| Phase                  | What it measures                            | Common causes                       |
| ---------------------- | ------------------------------------------- | ----------------------------------- |
| **Input Delay**        | Time from user input to event handler start | Long tasks blocking the main thread |
| **Processing Time**    | Event handler execution                     | Slow JavaScript, complex handlers   |
| **Presentation Delay** | Rendering after processing completes        | Large DOM updates, layout thrashing |

> **Note:** **How to use:** Run the snippet, interact with the page, then call `getInputLatencyBreakdown()` to see aggregated stats grouped by event type.

### Snippet

```js
// Input Latency Breakdown
// https://webperf-snippets.net

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

  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: "🔴",
  };

  // Interactions grouped by event type
  const byEventType = {};

  const observer = new PerformanceObserver((list) => {
    // Group entries by interactionId; keep the longest entry per interaction
    const interactions = {};

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

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

      const eventType = entry.name; // "click", "keydown", "pointerdown", etc.
      const inputDelay = entry.processingStart - entry.startTime;
      const processingTime = entry.processingEnd - entry.processingStart;
      const presentationDelay = Math.max(
        4,
        entry.startTime + entry.duration - entry.processingEnd
      );

      if (!byEventType[eventType]) {
        byEventType[eventType] = {
          count: 0,
          durations: [],
          inputDelays: [],
          processingTimes: [],
          presentationDelays: [],
        };
      }

      const bucket = byEventType[eventType];
      bucket.count++;
      bucket.durations.push(entry.duration);
      bucket.inputDelays.push(inputDelay);
      bucket.processingTimes.push(processingTime);
      bucket.presentationDelays.push(presentationDelay);
    }
  });

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

  const p75 = (arr) => {
    const sorted = [...arr].sort((a, b) => a - b);
    return (
      sorted[Math.floor(sorted.length * 0.75)] ?? sorted[sorted.length - 1]
    );
  };

  window.getInputLatencyBreakdown = () => {
    const types = Object.keys(byEventType);

    if (types.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: "Input-Latency-Breakdown",
        status: "error",
        error: "No interactions recorded yet. Interact with the page (click, type, etc.) and call getInputLatencyBreakdown() again.",
        getDataFn: "getInputLatencyBreakdown",
      };
    }

    console.group(
      "%c⌨️ Input Latency Breakdown by Event Type",
      "font-weight: bold; font-size: 14px;"
    );

    for (const eventType of types.sort()) {
      const b = byEventType[eventType];

      const p75Total = p75(b.durations);
      const p75InputDelay = p75(b.inputDelays);
      const p75Processing = p75(b.processingTimes);
      const p75Presentation = p75(b.presentationDelays);
      const p75Sum = p75InputDelay + p75Processing + p75Presentation;

      const phases = [
        { name: "Input Delay", value: p75InputDelay },
        { name: "Processing", value: p75Processing },
        { name: "Presentation", value: p75Presentation },
      ];
      const bottleneck = phases.reduce((a, b) => (a.value > b.value ? a : b));

      const rating = valueToRating(p75Total);
      const icon = RATING_ICONS[rating];
      const color = RATING_COLORS[rating];

      console.log(
        `%c${icon} ${eventType}%c (${b.count} interaction${b.count > 1 ? "s" : ""})  P75: ${formatMs(p75Total)}   Input Delay: ${formatMs(p75InputDelay)}  Processing: ${formatMs(p75Processing)}  Presentation: ${formatMs(p75Presentation)}`,
        `font-weight: bold; color: ${color};`,
        "color: inherit;"
      );

      // Visual distribution bar based on P75 phase values
      const barWidth = 36;
      const inputBar = "█".repeat(
        Math.max(1, Math.round((p75InputDelay / p75Sum) * barWidth))
      );
      const procBar = "▓".repeat(
        Math.max(1, Math.round((p75Processing / p75Sum) * barWidth))
      );
      const presBar = "░".repeat(
        Math.max(1, Math.round((p75Presentation / p75Sum) * barWidth))
      );
      console.log(`   ${inputBar}${procBar}${presBar}`);
      console.log(
        `   █ Input Delay (${((p75InputDelay / p75Sum) * 100).toFixed(0)}%)  ` +
          `▓ Processing (${((p75Processing / p75Sum) * 100).toFixed(0)}%)  ` +
          `░ Presentation (${((p75Presentation / p75Sum) * 100).toFixed(0)}%)`
      );

      if (rating !== "good") {
        console.log(
          `   ⚠️ Bottleneck: ${bottleneck.name} — `,
          bottleneck.name === "Input Delay"
            ? "break up long tasks blocking the main thread (scheduler.yield(), setTimeout)"
            : bottleneck.name === "Processing"
            ? "optimize event handlers or consider debouncing"
            : "reduce DOM changes or avoid layout thrashing after the handler"
        );
      }

      console.log("");
    }

    // Highlight the event type with the highest P75
    const worstType = types.reduce((a, b) =>
      p75(byEventType[a].durations) >= p75(byEventType[b].durations) ? a : b
    );
    const worstP75 = p75(byEventType[worstType].durations);

    if (valueToRating(worstP75) !== "good") {
      console.log(
        `%c🎯 Highest latency: ${worstType} (P75: ${formatMs(worstP75)})`,
        "font-weight: bold; color: #ef4444;"
      );
    }

    const typeSummary = {};
    for (const [type, b] of Object.entries(byEventType)) {
      typeSummary[type] = {
        count: b.count,
        p75Ms: Math.round(p75(b.durations)),
        inputDelayMs: Math.round(p75(b.inputDelays)),
        processingMs: Math.round(p75(b.processingTimes)),
        presentationMs: Math.round(p75(b.presentationDelays)),
      };
    }
    console.groupEnd();

    const BOTTLENECK_FIXES = {
      "Input Delay": "break up the long tasks that block the main thread (scheduler.yield(), setTimeout)",
      Processing: "optimize the event handlers or debounce them",
      Presentation: "reduce the DOM changes after the handler and avoid layout thrashing",
    };

    const items = types.map((type) => {
      const b = byEventType[type];
      return {
        type,
        count: b.count,
        p75Ms: Math.round(p75(b.durations)),
        inputDelayMs: Math.round(p75(b.inputDelays)),
        processingMs: Math.round(p75(b.processingTimes)),
        presentationMs: Math.round(p75(b.presentationDelays)),
        rating: valueToRating(p75(b.durations)),
      };
    });

    const issues = types
      .filter((type) => valueToRating(p75(byEventType[type].durations)) !== "good")
      .map((type) => {
        const b = byEventType[type];
        const phases = [
          { name: "Input Delay", value: p75(b.inputDelays) },
          { name: "Processing", value: p75(b.processingTimes) },
          { name: "Presentation", value: p75(b.presentationDelays) },
        ];
        const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
        const rating = valueToRating(p75(b.durations));
        return {
          severity: rating === "poor" ? "error" : "warning",
          message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
        };
      });

    return {
      script: "Input-Latency-Breakdown",
      status: "ok",
      count: types.length,
      details: { eventTypes: typeSummary },
      items,
      issues,
    };
  };

  console.log(
    "%c⌨️ Input Latency Breakdown Active",
    "font-weight: bold; font-size: 14px;"
  );
  console.log("   Interact with the page (click, type, etc.).");
  console.log(
    "   Call %cgetInputLatencyBreakdown()%c for the aggregated report.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );

  return {
    script: "Input-Latency-Breakdown",
    status: "tracking",
    message: "Tracking input latency by event type. Interact with the page then call getInputLatencyBreakdown().",
    getDataFn: "getInputLatencyBreakdown",
  };
})();
```

### Understanding the results

Each event type prints a single line with its P75 values across all three phases, followed by a distribution bar:

```
🟡 click (12 interactions)  P75: 280ms   Input Delay: 45ms  Processing: 28ms  Presentation: 207ms
   ██████▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░
   █ Input Delay (16%)  ▓ Processing (10%)  ░ Presentation (74%)
   ⚠️ Bottleneck: Presentation — reduce DOM changes or avoid layout thrashing after the handler
```

All values are **P75**, the same percentile INP uses, so they reflect typical behavior rather than outliers.

### See the grouping

Add interactions of three event types and watch how the P75 of each phase builds up per type, and why a single slow outlier does not change the picture:

Interactive demo: [Interactive simulation that groups click, key press and pointer down interactions by event type, with the P75 of input delay, processing time and presentation delay, the bottleneck of each type and the list of individual interactions behind it](https://webperf-snippets.net/demos/input-latency-by-event.html). Add interactions one at a time or in a batch, and compare which phase dominates in each event type.

### Bottleneck identification

| Bottleneck       | Likely cause                    | Fix                                        |
| ---------------- | ------------------------------- | ------------------------------------------ |
| **Input Delay**  | Long tasks run before the event | `scheduler.yield()`, task splitting        |
| **Processing**   | Slow event handlers             | Optimize handlers, debounce rapid events   |
| **Presentation** | Expensive render after handler  | Reduce DOM changes, avoid layout thrashing |

### How this differs from interactions

| Snippet                                                                  | Focus                                                                   |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| [Interactions](https://webperf-snippets.net/Interaction/Interactions.md) | Per-interaction breakdown, real-time, with optimization hints per event |
| **Input Latency Breakdown**                                              | P75 by event type, reveals systematic patterns across many interactions |

Use both together: `Interactions` to catch individual slow events as they happen, and `getInputLatencyBreakdown()` after several interactions to identify which event type is systematically problematic.

### 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 in the field](https://web.dev/articles/find-slow-interactions-in-the-field) | web.dev
- [scheduler.yield()](https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield) | MDN
