|
| 1 | +# Input Latency Breakdown |
| 2 | + |
| 3 | +### Overview |
| 4 | + |
| 5 | +Aggregates interaction latency by event type to reveal which phase causes slowness across all interactions with the page. While [Interactions](/Interaction/Interactions) 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?** |
| 6 | + |
| 7 | +**Why this matters:** |
| 8 | + |
| 9 | +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. |
| 10 | + |
| 11 | +**The three phases of every interaction:** |
| 12 | + |
| 13 | +| Phase | What it measures | Common causes | |
| 14 | +|-------|-----------------|---------------| |
| 15 | +| **Input Delay** | Time from user input to event handler start | Long tasks blocking the main thread | |
| 16 | +| **Processing Time** | Event handler execution | Slow JavaScript, complex handlers | |
| 17 | +| **Presentation Delay** | Rendering after processing completes | Large DOM updates, layout thrashing | |
| 18 | + |
| 19 | +> **How to use:** Run the snippet, interact with the page, then call `getInputLatencyBreakdown()` to see aggregated stats grouped by event type. |
| 20 | +
|
| 21 | +### Snippet |
| 22 | + |
| 23 | +```js copy |
| 24 | +// Input Latency Breakdown |
| 25 | +// https://webperf-snippets.nucliweb.net |
| 26 | + |
| 27 | +(() => { |
| 28 | + const formatMs = (ms) => `${Math.round(ms)}ms`; |
| 29 | + |
| 30 | + const valueToRating = (score) => |
| 31 | + score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor"; |
| 32 | + |
| 33 | + const RATING_COLORS = { |
| 34 | + good: "#0CCE6A", |
| 35 | + "needs-improvement": "#FFA400", |
| 36 | + poor: "#FF4E42", |
| 37 | + }; |
| 38 | + |
| 39 | + const RATING_ICONS = { |
| 40 | + good: "🟢", |
| 41 | + "needs-improvement": "🟡", |
| 42 | + poor: "🔴", |
| 43 | + }; |
| 44 | + |
| 45 | + // Interactions grouped by event type |
| 46 | + const byEventType = {}; |
| 47 | + |
| 48 | + const observer = new PerformanceObserver((list) => { |
| 49 | + // Group entries by interactionId; keep the longest entry per interaction |
| 50 | + const interactions = {}; |
| 51 | + |
| 52 | + for (const entry of list.getEntries().filter((e) => e.interactionId)) { |
| 53 | + interactions[entry.interactionId] = |
| 54 | + interactions[entry.interactionId] || []; |
| 55 | + interactions[entry.interactionId].push(entry); |
| 56 | + } |
| 57 | + |
| 58 | + for (const group of Object.values(interactions)) { |
| 59 | + const entry = group.reduce((prev, curr) => |
| 60 | + prev.duration >= curr.duration ? prev : curr |
| 61 | + ); |
| 62 | + |
| 63 | + const eventType = entry.name; // "click", "keydown", "pointerdown", etc. |
| 64 | + const inputDelay = entry.processingStart - entry.startTime; |
| 65 | + const processingTime = entry.processingEnd - entry.processingStart; |
| 66 | + const presentationDelay = Math.max( |
| 67 | + 4, |
| 68 | + entry.startTime + entry.duration - entry.processingEnd |
| 69 | + ); |
| 70 | + |
| 71 | + if (!byEventType[eventType]) { |
| 72 | + byEventType[eventType] = { |
| 73 | + count: 0, |
| 74 | + durations: [], |
| 75 | + inputDelays: [], |
| 76 | + processingTimes: [], |
| 77 | + presentationDelays: [], |
| 78 | + }; |
| 79 | + } |
| 80 | + |
| 81 | + const bucket = byEventType[eventType]; |
| 82 | + bucket.count++; |
| 83 | + bucket.durations.push(entry.duration); |
| 84 | + bucket.inputDelays.push(inputDelay); |
| 85 | + bucket.processingTimes.push(processingTime); |
| 86 | + bucket.presentationDelays.push(presentationDelay); |
| 87 | + } |
| 88 | + }); |
| 89 | + |
| 90 | + observer.observe({ type: "event", durationThreshold: 0, buffered: true }); |
| 91 | + |
| 92 | + const p75 = (arr) => { |
| 93 | + const sorted = [...arr].sort((a, b) => a - b); |
| 94 | + return ( |
| 95 | + sorted[Math.floor(sorted.length * 0.75)] ?? sorted[sorted.length - 1] |
| 96 | + ); |
| 97 | + }; |
| 98 | + |
| 99 | + window.getInputLatencyBreakdown = () => { |
| 100 | + const types = Object.keys(byEventType); |
| 101 | + |
| 102 | + if (types.length === 0) { |
| 103 | + console.log("%c⌨️ No interactions recorded yet.", "font-weight: bold;"); |
| 104 | + console.log( |
| 105 | + " Interact with the page (click, type, etc.) and call this again." |
| 106 | + ); |
| 107 | + return; |
| 108 | + } |
| 109 | + |
| 110 | + console.group( |
| 111 | + "%c⌨️ Input Latency Breakdown by Event Type", |
| 112 | + "font-weight: bold; font-size: 14px;" |
| 113 | + ); |
| 114 | + |
| 115 | + for (const eventType of types.sort()) { |
| 116 | + const b = byEventType[eventType]; |
| 117 | + |
| 118 | + const p75Total = p75(b.durations); |
| 119 | + const p75InputDelay = p75(b.inputDelays); |
| 120 | + const p75Processing = p75(b.processingTimes); |
| 121 | + const p75Presentation = p75(b.presentationDelays); |
| 122 | + const p75Sum = p75InputDelay + p75Processing + p75Presentation; |
| 123 | + |
| 124 | + const phases = [ |
| 125 | + { name: "Input Delay", value: p75InputDelay }, |
| 126 | + { name: "Processing", value: p75Processing }, |
| 127 | + { name: "Presentation", value: p75Presentation }, |
| 128 | + ]; |
| 129 | + const bottleneck = phases.reduce((a, b) => (a.value > b.value ? a : b)); |
| 130 | + |
| 131 | + const rating = valueToRating(p75Total); |
| 132 | + const icon = RATING_ICONS[rating]; |
| 133 | + const color = RATING_COLORS[rating]; |
| 134 | + |
| 135 | + console.log( |
| 136 | + `%c${icon} ${eventType}%c (${b.count} interaction${b.count > 1 ? "s" : ""}) P75: ${formatMs(p75Total)} Input Delay: ${formatMs(p75InputDelay)} Processing: ${formatMs(p75Processing)} Presentation: ${formatMs(p75Presentation)}`, |
| 137 | + `font-weight: bold; color: ${color};`, |
| 138 | + "color: inherit;" |
| 139 | + ); |
| 140 | + |
| 141 | + // Visual distribution bar based on P75 phase values |
| 142 | + const barWidth = 36; |
| 143 | + const inputBar = "█".repeat( |
| 144 | + Math.max(1, Math.round((p75InputDelay / p75Sum) * barWidth)) |
| 145 | + ); |
| 146 | + const procBar = "▓".repeat( |
| 147 | + Math.max(1, Math.round((p75Processing / p75Sum) * barWidth)) |
| 148 | + ); |
| 149 | + const presBar = "░".repeat( |
| 150 | + Math.max(1, Math.round((p75Presentation / p75Sum) * barWidth)) |
| 151 | + ); |
| 152 | + console.log(` ${inputBar}${procBar}${presBar}`); |
| 153 | + console.log( |
| 154 | + ` █ Input Delay (${((p75InputDelay / p75Sum) * 100).toFixed(0)}%) ` + |
| 155 | + `▓ Processing (${((p75Processing / p75Sum) * 100).toFixed(0)}%) ` + |
| 156 | + `░ Presentation (${((p75Presentation / p75Sum) * 100).toFixed(0)}%)` |
| 157 | + ); |
| 158 | + |
| 159 | + if (rating !== "good") { |
| 160 | + console.log( |
| 161 | + ` ⚠️ Bottleneck: ${bottleneck.name} — `, |
| 162 | + bottleneck.name === "Input Delay" |
| 163 | + ? "break up long tasks blocking the main thread (scheduler.yield(), setTimeout)" |
| 164 | + : bottleneck.name === "Processing" |
| 165 | + ? "optimize event handlers or consider debouncing" |
| 166 | + : "reduce DOM changes or avoid layout thrashing after the handler" |
| 167 | + ); |
| 168 | + } |
| 169 | + |
| 170 | + console.log(""); |
| 171 | + } |
| 172 | + |
| 173 | + // Highlight the event type with the highest P75 |
| 174 | + const worstType = types.reduce((a, b) => |
| 175 | + p75(byEventType[a].durations) >= p75(byEventType[b].durations) ? a : b |
| 176 | + ); |
| 177 | + const worstP75 = p75(byEventType[worstType].durations); |
| 178 | + |
| 179 | + if (valueToRating(worstP75) !== "good") { |
| 180 | + console.log( |
| 181 | + `%c🎯 Highest latency: ${worstType} (P75: ${formatMs(worstP75)})`, |
| 182 | + "font-weight: bold; color: #ef4444;" |
| 183 | + ); |
| 184 | + } |
| 185 | + |
| 186 | + console.groupEnd(); |
| 187 | + }; |
| 188 | + |
| 189 | + console.log( |
| 190 | + "%c⌨️ Input Latency Breakdown Active", |
| 191 | + "font-weight: bold; font-size: 14px;" |
| 192 | + ); |
| 193 | + console.log(" Interact with the page (click, type, etc.)."); |
| 194 | + console.log( |
| 195 | + " Call %cgetInputLatencyBreakdown()%c for the aggregated report.", |
| 196 | + "font-family: monospace; background: #f3f4f6; padding: 2px 4px;", |
| 197 | + "" |
| 198 | + ); |
| 199 | +})(); |
| 200 | +``` |
| 201 | +
|
| 202 | +### Understanding the Results |
| 203 | +
|
| 204 | +Each event type prints a single line with its P75 values across all three phases, followed by a distribution bar: |
| 205 | +
|
| 206 | +``` |
| 207 | +🟡 click (12 interactions) P75: 280ms Input Delay: 45ms Processing: 28ms Presentation: 207ms |
| 208 | + ██████▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░ |
| 209 | + █ Input Delay (16%) ▓ Processing (10%) ░ Presentation (74%) |
| 210 | + ⚠️ Bottleneck: Presentation — reduce DOM changes or avoid layout thrashing after the handler |
| 211 | +``` |
| 212 | +
|
| 213 | +All values are **P75** — the same percentile INP uses — so they reflect typical behavior rather than outliers. |
| 214 | +
|
| 215 | +**Bottleneck identification:** |
| 216 | +
|
| 217 | +| Bottleneck | Likely cause | Fix | |
| 218 | +|-----------|-------------|-----| |
| 219 | +| **Input Delay** | Long tasks run before the event | `scheduler.yield()`, task splitting | |
| 220 | +| **Processing** | Slow event handlers | Optimize handlers, debounce rapid events | |
| 221 | +| **Presentation** | Expensive render after handler | Reduce DOM changes, avoid layout thrashing | |
| 222 | +
|
| 223 | +### How this differs from Interactions |
| 224 | +
|
| 225 | +| Snippet | Focus | |
| 226 | +|---------|-------| |
| 227 | +| [Interactions](/Interaction/Interactions) | Per-interaction breakdown, real-time, with optimization hints per event | |
| 228 | +| **Input Latency Breakdown** | P75 by event type, reveals systematic patterns across many interactions | |
| 229 | +
|
| 230 | +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. |
| 231 | +
|
| 232 | +### Further Reading |
| 233 | +
|
| 234 | +- [Interaction to Next Paint (INP)](https://web.dev/articles/inp) | web.dev |
| 235 | +- [Optimize INP](https://web.dev/articles/optimize-inp) | web.dev |
| 236 | +- [Find slow interactions in the field](https://web.dev/articles/find-slow-interactions-in-the-field) | web.dev |
| 237 | +- [scheduler.yield()](https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield) | MDN |
0 commit comments