Finding the Slowest Interaction in the Performance Panel
This scenario page belongs to profiling event handlers for INP under Core Web Vitals & Measurement, and it solves one narrow problem: you have a recording full of clicks, scrolls, and typing, your field INP is over 200ms, and you need to find the one interaction that owns that number.
The trap is that a long session contains dozens of interactions and most are fast. INP reports the worst (or near-worst), so eyeballing the flame chart for "something big" wastes time — the dominant interaction may be a 220ms bar surrounded by fast 30ms ones, easy to miss. This is a search problem first and an optimization problem second.
Rapid Diagnosis Checklist
Before recording, confirm the setup that makes the slow bar visible:
- CPU throttling is on. Set
4x(or6x) slowdown in the Performance panel gear menu. An unthrottled desktop hides the regression that real mid-tier Android exposes. - The Interactions track is visible. It sits directly under the main timeline ruler. If it is collapsed, expand it — this track, not the flame chart, is where you find the bar.
- You reproduced the real interaction. INP is driven by
click,tap, and keyboard input, not scroll. If you only scrolled, there is nothing to find. - The recording is short and targeted. Record the suspect flow only (open the menu, type in the filter, submit), not a five-minute browse. A tight recording makes the widest bar obvious.
- You know the field target. Pull the worst-interaction element selector from RUM first if you have it, so you know which interaction to reproduce.
Root Cause Analysis: Why the Slow Interaction Hides
The widest bar is not the busiest flame chart region. Engineers scan the main thread track for the tallest stack and assume that is the slow interaction. But a tall stack during page load is not an interaction at all, and the slow interaction may have a modest-looking stack whose cost is input delay — time before the handler ran — which shows as a gap, not a stack. The Interactions track measures total latency including that gap; the flame chart does not.
Input delay makes the handler look innocent. When the main thread was busy at click time, the interaction's latency is dominated by the wait before the listener even starts. The handler's own flame-chart block is small, so it gets overlooked, yet the Interactions bar is wide. You must read the bar, not the stack, to see this.
Multiple interactions blur together. Rapid typing fires an input interaction per keystroke. Several 60ms interactions in a row can look like one long region, hiding the single 210ms keystroke that actually fails. Sorting by latency, not scanning visually, separates them.
The worst field interaction never happens in the lab. If you reproduce the wrong flow, the slow bar simply is not in your recording. The cure is to seed the lab run from the RUM interactionTarget selector so you reproduce the exact element that fails in the field.
Throttling masks or exaggerates the real bottleneck. Too little CPU throttling and the slow interaction never crosses 200ms locally, so it never appears as a standout bar; too much and unrelated work balloons, burying the true culprit under noise. The reliable setting for INP work is 4x slowdown, which approximates the mid-tier Android hardware that dominates the field p75. If a bar looks slow only at 6x but fine at 4x, you are chasing a device class your field data does not represent — calibrate the throttle to your RUM device mix, not to whatever makes a bar look dramatic.
Self-time versus total time confusion in the stack. Once you drill into the flame chart, the tallest block is often a framework entry point whose total time is large but whose self time is trivial — the real cost is a child call. Switching the Summary view to sort by self-time, rather than reading the deepest visible frame, is what points at the function that actually spends the budget. Reading total time here sends you to optimize a wrapper that merely calls the slow code.
Step-by-Step Resolution
1. Record the targeted flow. Open the Performance panel, confirm 4x throttling, click record, perform only the suspect interactions, and stop. Keep it under ~15 seconds. Expected outcome: a recording where the Interactions track shows a small, countable set of bars rather than a wall of them.
2. Read the Interactions track, not the flame chart. Each interaction is a bar whose width is its measured latency. Hover any bar for a tooltip with the interaction type and total duration. The widest bar is your INP candidate. Expected outcome: you identify a single bar — for example a 230ms click — that exceeds the 200ms boundary while the rest sit under 80ms.
3. Confirm the ranking with the Event Timing API. Visual width can deceive across zoom levels, so confirm numerically. Paste this into the Console before re-running the flow and read the sorted list:
const entries = [];
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.interactionId) entries.push({ name: e.name, dur: Math.round(e.duration), id: e.interactionId });
}
// sort descending so the worst interaction is always row 0
console.table([...entries].sort((a, b) => b.dur - a.dur).slice(0, 5));
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
// trade-off: durationThreshold:16 captures everything for a clean ranking but is noisy
// and adds per-event overhead — use it only for this manual hunt, never in production RUM.
Expected outcome: a table whose top row is the worst interaction's name and duration, matching the widest bar and removing any doubt about which one to chase.
4. Expand the bar into its three phases. Click the widest bar in the Interactions track. DevTools highlights the input-delay segment (the striped lead-in before processing), the processing block, and the presentation gap before the next frame commits. Expected outcome: you can see at a glance which phase fills the bar — a long striped lead-in means input delay, a fat middle means processing, a trailing gap means presentation delay.
5. Drill from the bar into the responsible stack. With the bar selected, look at the main-thread flame chart directly beneath it during the processing block. The dominant function in that stack is the handler (or the framework re-render it triggered) that owns the time. Right-click and "show in summary" to get the self-time. Expected outcome: a named function and file — for example a filterRows call inside an onChange handler running for 170ms.
6. Cross-check with LoAF for an exact source position. If the flame chart bundles several callbacks, the Long Animation Frames API attributes the frame down to the source. Run this and re-trigger the interaction:
new PerformanceObserver((list) => {
for (const f of list.getEntries()) {
if (f.blockingDuration === 0) continue;
// invoker names the handler; sourceCharPosition points at the line
f.scripts.sort((a, b) => b.duration - a.duration);
console.log(f.scripts[0]?.invoker, f.scripts[0]?.sourceURL, f.scripts[0]?.sourceCharPosition);
}
}).observe({ type: 'long-animation-frame', buffered: true });
// trade-off: LoAF reports at frame granularity, so a frame with several similar-duration
// scripts needs the flame chart to disambiguate — treat invoker as a strong hint, not proof.
Expected outcome: the handler name and exact character position of the slow code, closing the loop from "worst bar" to "this line."
Verification
Confirm you found the right interaction before you spend time fixing it. Re-run the Console ranking from step 3 across a fresh recording of the same flow; the worst interaction should be stable across runs — if a different bar tops the table each time, you are measuring noise and need a quieter machine or heavier throttling. After a fix lands, the before/after is unambiguous: the previously widest bar should drop below the 200ms line, and the step 3 table's top row should fall under your target. Lock it in CI with a scripted assertion:
// playwright: fail if any long frame during the flow blocks beyond the budget
const blocking = await page.evaluate(() => performance
.getEntriesByType('long-animation-frame')
.reduce((max, f) => Math.max(max, f.blockingDuration), 0));
expect(blocking).toBeLessThan(50); // 50ms long-frame budget
// trade-off: this asserts the lab flow only — a slow interaction on a route you did not
// script stays invisible, so keep the RUM p75 INP check as the source of truth.
Finally, confirm the field: the p75 INP for the affected interaction's selector should drop in your RUM dashboard. The lab hunt finds the interaction; the field number proves it was the one that mattered. For the deeper attribution model and the fixes that follow, return to profiling event handlers for INP.
Related
- Profiling event handlers for INP — the full attribution workflow this hunt feeds into.
- Optimizing INP with scheduler.yield() — split the slow handler once you have located it.
- Reducing input delay from third-party tags — when the slow bar is dominated by its input-delay segment.
- Offloading work to web workers with Comlink — when the dominant function is too heavy to keep on the main thread.