Profiling Event Handlers for INP: Attributing Slow Interactions to a Specific Handler
This guide sits under Core Web Vitals & Measurement and turns a failing Interaction to Next Paint number into a named function, a named phase, and a fixable line of code.
Interaction to Next Paint fails when a single interaction's total latency crosses the 200ms "good" boundary, and the field metric reports the worst (or near-worst) interaction across the whole visit. That makes INP a needle-in-a-haystack problem: your median interaction can be 40ms while one buried click on one route ships 420ms and fails the page. You cannot fix it until you can answer three questions precisely — which interaction, which of the three INP phases dominates it, and which function inside that phase owns the time. Profiling is the discipline that answers all three. This guide walks the full attribution loop: set up the capture environment, record a real baseline, isolate the dominant phase, and pin the cost to a specific event handler using the DevTools Performance panel, the Event Timing API, and the Long Animation Frames (LoAF) API.
Every interaction's INP decomposes into input delay (action to first listener), processing duration (all listeners running), and presentation delay (listeners done to next paint). Attribution is the act of mapping a slow number onto one of those three phases and then onto the code that fills it. Get the phase wrong and you optimize the wrong layer — adding yield points to a paint-bound interaction, or rewriting a handler that was never the bottleneck.
1. Environment Setup for Reproducible Interaction Capture
Profiling interactions is only useful when the capture is reproducible, so fix the variables that move INP before you record anything. Use a Chromium build at version 123 or later (the Performance panel's Interactions track and LoAF support are stable there), test in an incognito window to exclude extensions, and apply 4x CPU throttling under the Performance panel's gear menu so your local run approximates a mid-tier Android device. INP is dominated by the slow tail of real hardware; an unthrottled M-series laptop will hide every regression that matters.
Install the web-vitals attribution build, which exposes the per-phase breakdown and the LoAF entries you will correlate against the flame chart:
# trade-off: the attribution build is larger than the core build (it carries the
# Event Timing + LoAF plumbing), so ship it only in a sampled RUM bundle, not to
# 100% of traffic — load the core build for production beacons if size is tight.
npm install web-vitals@4
Decide up front whether you are profiling in the lab (DevTools, deterministic, reproducible) or in the field (RUM, real devices, the number that actually ships). Both are required: the lab tells you why an interaction is slow, the field tells you which interaction to chase and whether your fix moved the p75. This is the same field-versus-lab split that governs all of Core Web Vitals & Measurement — lab numbers locate the bottleneck, field p75 is the boundary that ships.
2. Capture a Baseline with Event Timing and the Performance Panel
You cannot attribute what you have not recorded. Capture the baseline two ways — programmatically with the Event Timing API for breadth, and in the Performance panel for depth on the single worst interaction.
The Event Timing API surfaces every qualifying interaction with the exact timestamps that define each phase. startTime is when the input occurred, processingStart and processingEnd bracket your listeners, and the entry's duration runs to the next paint. From those four numbers the three phases fall out directly:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue; // only real interactions, not raw events
const inputDelay = entry.processingStart - entry.startTime;
const processing = entry.processingEnd - entry.processingStart;
const presentation = entry.startTime + entry.duration - entry.processingEnd;
console.log(entry.name, { inputDelay, processing, presentation, total: entry.duration });
}
});
// durationThreshold:40 captures interactions at/above the threshold; 16 is the floor.
observer.observe({ type: 'event', durationThreshold: 40, buffered: true });
// trade-off: a low durationThreshold floods the callback with fast interactions and
// adds observer overhead on every event — keep it at 40+ in production and only drop
// to 16 during a focused local debugging session.
For the worst interaction, switch to the Performance panel. Start a recording, perform the interaction, stop, and read the Interactions track at the top of the timeline. Each interaction appears as a bar whose width is its measured latency; the widest bar is your INP candidate. Click it and the bar reveals the input-delay segment (a striped lead-in), the processing block, and the presentation gap before the next frame commits. The full mechanical workflow for hunting that bar in a long, messy recording is covered in finding the slowest interaction in the Performance panel. Record the baseline number now — the worst interaction's total latency and the width of its dominant phase — because every later step is judged against it.
3. Isolate the Dominant Phase Before Touching Any Handler
With the phase breakdown from step 2, decide which of the three phases owns the latency. This is the single most important decision in the loop, because each phase has a different cure and optimizing the wrong one wastes effort.
If input delay dominates, the main thread was already busy with an unrelated task when the interaction arrived — your handler had not even started yet. The fix lives in the upstream task, not the handler: split whatever long task was mid-flight at click time, often a hydration step, a route transition, or a third-party script. If a chat widget or analytics tag is the upstream culprit, reducing input delay from third-party tags is the targeted playbook.
If processing duration dominates, your own listeners are the long task. This is the most common case in JavaScript-heavy apps, and the cure is splitting the handler with scheduler.yield() or moving the heavy compute off-thread. If presentation delay dominates, listeners finished quickly but the browser could not paint in time — a large DOM, an expensive style recalc, or a forced synchronous layout. No amount of scheduling helps here; reduce the commit size with content-visibility and fewer layout-triggering reads.
A fast rule of thumb in the flame chart: if a task is running when the click arrives, it is input delay; if your handler runs for a long stretch after the click, it is processing; if there is a gap between your handler finishing and the next frame, it is presentation delay.
4. Attribute the Cost to a Specific Handler with LoAF
Phase isolation tells you which layer to fix; LoAF tells you exactly which script and function fills it. A Long Animation Frame is any frame that takes longer than 50ms to render, and unlike the older Long Tasks API it attributes the blocking work down to a source script, character position, and the responsible callback. This is what turns "something is slow" into "this onChange handler in Filters.tsx ran for 180ms."
const loafObserver = new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
// blockingDuration is the part of the frame over the 50ms budget — the INP-relevant slice.
if (frame.blockingDuration === 0) continue;
for (const script of frame.scripts) {
console.log({
invoker: script.invoker, // e.g. "BUTTON.onclick" — the handler that ran
source: script.sourceURL, // the file
char: script.sourceCharPosition,// the exact position in that file
duration: script.duration,
forcedReflow: script.forcedStyleAndLayoutDuration, // layout thrash inside the handler
});
}
}
});
loafObserver.observe({ type: 'long-animation-frame', buffered: true });
// trade-off: LoAF reports at frame granularity, so a frame bundling several small
// callbacks attributes them together — when invoker is ambiguous, drop back to the
// Performance panel flame chart to separate them by stack.
The invoker field is the attribution key: it names the handler (BUTTON.onclick, INPUT.oninput, or a framework-internal callback) that drove the long frame, and sourceCharPosition points at the line. Cross-reference the LoAF startTime with the Event Timing entry's startTime for the same interaction and you have closed the loop — a slow interaction, its dominant phase, and the named function that owns the time. Send both to your RUM endpoint so the field data carries attribution, not just a bare INP value:
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
navigator.sendBeacon('/rum/inp', JSON.stringify({
value,
target: attribution.interactionTarget, // CSS selector of the element
phase: {
input: attribution.inputDelay,
processing: attribution.processingDuration,
presentation: attribution.presentationDelay,
},
longestScript: attribution.longAnimationFrameEntries?.[0]?.scripts?.[0]?.invoker,
}));
// trade-off: shipping the full LoAF script list per beacon is heavy; send only the
// top invoker and duration, and reserve the full array for a low-sample debug cohort.
});
Deconstructing INP: The Three Phases and Their Budgets
INP for one interaction is the additive sum of three phases against the 200ms total, and each has a distinct diagnostic and budget:
- Input delay — from the user's action to the first listener starting. Caused by the main thread being busy when the interaction arrives. Budget well under
50ms. Attribute it with the LoAF entry whose frame overlaps the click and with the busy task visible in the flame chart before processing begins. The cure is upstream: split the blocking task or defer the script that owns it. - Processing duration — all listeners for the interaction, including framework state updates and re-render scheduling. Usually the largest phase in interactive apps. Budget
< 100ms. Attribute it with Event Timing'sprocessingEnd - processingStartand the LoAFinvoker. The cure is splitting the handler and deferring non-urgent work to a lower priority. - Presentation delay — from listeners finishing to the next paint. Caused by style, layout, and paint cost on commit. Budget
< 50ms. Attribute it by the gap betweenprocessingEndand the entry's end in the timeline, plusforcedStyleAndLayoutDurationfrom LoAF. The cure is a smaller commit, not scheduling.
The classic misattribution: a handler measures 40ms of processing yet ships 300ms INP because a 220ms task was mid-flight at click time, inflating input delay. The phase breakdown sends you to the upstream task — exactly the failure mode dissected in improving INP for complex single page applications, where router transitions and hydration commonly fill the input-delay phase.
Advanced Diagnostics and Framework Edge Cases
LoAF frames that bundle multiple callbacks. A single long animation frame can contain several scripts — a click handler, a requestAnimationFrame callback, and a microtask flush — and LoAF attributes them to one frame. When the scripts array has several entries with similar durations, the dominant one is not always the handler; sort by duration and check forcedStyleAndLayoutDuration to separate compute cost from layout thrash inside the same frame.
Framework re-renders hiding inside processing. In React, Vue, and Angular the visible onClick is thin; the real cost is the state update it triggers, which runs as part of the same interaction's processing phase. The Event Timing entry attributes that to the originating event, so a 12ms handler can show 150ms of processing because the commit it scheduled ran synchronously. Read the flame chart under the processing block to see the framework's reconciliation work — that is where the budget actually goes, and it is why breaking up long tasks in React event handlers targets the update, not the listener.
Pointer interactions reporting twice. A tap produces pointerdown, pointerup, and click, and the browser groups them under one interactionId. When you read raw event entries instead of grouping by interactionId, you double-count and misattribute. Always filter on entry.interactionId before computing phases, as in the step 2 snippet.
Compute that should never be on the main thread. If the processing phase is dominated by parsing, diffing, or serialization, profiling will keep pointing at the same function no matter how you slice it — the answer is to leave the thread entirely. See offloading work to web workers with Comlink; yielding makes a 400ms parse interruptible but never cheap, while a worker removes it from the interaction's critical path.
Buffered entries you missed before the observer attached. An interaction that happens during initial load can fire before your PerformanceObserver is registered, and without buffered: true you lose it entirely — the most damaging interactions (the first click after a slow hydration) are exactly the ones most likely to predate observer setup. Always pass buffered: true on both the event and long-animation-frame observers, and register them as early as possible in the document head. The buffer is bounded, so a page that fires hundreds of events before script runs can still drop the oldest entries; when you suspect that, move the observer registration ahead of every other inline script.
Distinguishing input delay from a slow requestAnimationFrame. A common misread is to see a long frame straddling the interaction and assume the handler is slow, when in fact a heavy requestAnimationFrame callback — an animation library, a canvas redraw — was running at click time and inflated input delay. LoAF attributes the rAF callback explicitly in its scripts array with its own invoker, so check whether the dominant script is your handler or an animation callback before deciding where to fix. The cure for the latter is to throttle or pause the animation while interactions are likely, not to touch the handler at all.
Validation and Budgeting in CI
Attribution is proven by the long-frame profile and the field INP after a fix ships, so assert both. In Lighthouse CI, gate the lab proxy for main-thread blocking so a processing regression cannot merge:
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'mainthread-work-breakdown': ['warn', { maxNumericValue: 2000 }],
'interactive': ['warn', { maxNumericValue: 3500 }],
},
},
},
};
// trade-off: TBT is a load-time proxy and never sees post-load interactions — a green
// TBT with red field INP means the slow interaction is a route change or modal, so
// pair this gate with a scripted interaction assertion and RUM.
Add a Playwright or Puppeteer script that performs the target interaction while recording long-animation-frame entries, then asserts that no frame's blockingDuration exceeds 50ms. This catches the regression TBT misses — a slow interaction that only happens after load, where page-load metrics stay green. Run it in the same pipeline stage as Lighthouse so one gate covers load-time and interaction-time blocking. Finally, confirm the field p75 INP moved in your RUM dashboard before and after; treat the fix as proven only when the field number drops, since synthetic devices rarely reproduce the slow tail. The lab gate blocks the regression from merging; the field check confirms the win. The complete CI harness is detailed in the Lighthouse CI setup for frontend pipelines.
Related
- Optimizing INP with scheduler.yield() — once you have attributed a slow handler, split it with the modern scheduling APIs.
- Finding the slowest interaction in the Performance panel — the mechanical hunt for the worst interaction bar in a long recording.
- Reducing input delay from third-party tags — when analytics, ads, or chat scripts inflate the input-delay phase.
- Offloading work to web workers with Comlink — when a handler's compute is too heavy to keep on the main thread at all.
- Improving INP for complex single page applications — phase attribution applied to router transitions and hydration.