Skip to content

Commit 11b2522

Browse files
committed
Add Input Latency Breakdown snippet
Aggregates INP sub-parts (input delay, processing, presentation) by event type using P75 values, surfacing which phase is the systematic bottleneck across clicks, keypresses, and other interaction types.
1 parent cd82049 commit 11b2522

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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

pages/Interaction/_meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"Interactions": "Interactions",
3+
"Input-Latency-Breakdown": "Input Latency Breakdown",
34
"Layout-Shift-Loading-and-Interaction": "Layout Shift Loading and Interaction",
45
"Long-Animation-Frames": "Long Animation Frames",
56
"Long-Animation-Frames-Script-Attribution": "Long Animation Frames Script Attribution",

0 commit comments

Comments
 (0)