HomeJournalThis post

PerformanceObserver INP: Debug Slow Interactions

Capture one reproducible interaction receipt that separates input delay, handler work, presentation delay, target, and surrounding long tasks.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

PerformanceObserver INP debugging begins with one slow local interaction, not a guessed site score. Capture only the fields the browser exposes, then use phase evidence to choose the next trace.

PerformanceObserver INP starts with a local boundary

PerformanceObserver INP debugging can expose slow interaction entries and phase timestamps in a controlled browser session. That evidence is local and diagnostic: it is not Chrome UX Report field data, one run is not a site’s INP score, and unsupported interaction IDs must remain unavailable rather than being invented.

The Event Timing specification defines event entries, duration, processing boundaries, interaction identifiers, and exposure rules. Feature-detect supported entry types and record the browser, because attribution and buffering can differ by implementation and version.

The lab is intentionally nondeterministic. It exports a timestamped user-agent receipt from a chosen synthetic workload, labels observed and unavailable fields, and reports unexpected exceptions as failures instead of manufacturing a green metric.

Begin with one interaction the local user can repeat and one question about its delay. Broad page instrumentation produces many events but little diagnosis; a controlled filter action with labeled work phases makes it possible to connect an observer entry to application marks and a follow-up trace.

Warm-up and cache state change local results. Run a cold navigation, a warm repeat, and the interaction after realistic idle time; discard or label trials affected by DevTools overhead, extensions, background tabs, thermal throttling, or compilation. Report a distribution across several repeats, not the best trace selected for a screenshot. The nondeterministic lab makes this variability visible rather than pinning a fake golden duration.

Choose one reproducible interaction

Name the user action, starting state, route, data fixture, cache state, viewport, input method, browser, CPU condition, and expected UI result. A search keystroke, menu activation, editor paste, or table filter exercises different work; mixing them into one manual session makes a slow entry difficult to reproduce.

PerformanceObserver INP analysis benefits from a small workload toggle that creates enough main-thread work to inspect without pretending to mimic production. The artifact renders a known number of elements after a bounded busy loop, then waits for paint so its marks bracket a visible local operation.

Attach the interaction to product UI observability with privacy-safe route and journey identifiers. Avoid recording typed content or DOM text merely to identify a target.

The Event Timing API exposes duration and interaction identifiers under specific browser and threshold conditions. Feature-detect supported entry types, use buffered observation when available, and record unavailable fields explicitly; zero, missing, and unsupported are three different states in a trustworthy receipt.

Runnable artifact — Local interaction evidence is not CrUX field data, one run is not a site INP score, and unsupported attribution fields must be reported as unavailable.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PerformanceObserver INP grouping lab</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.45 system-ui;background:#091318;color:#f4f7f6;max-width:980px;margin:auto;padding:24px}main{display:grid;gap:16px}fieldset,.panel{border:1px solid #8aa0aa;border-radius:12px;padding:14px}fieldset{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}label{display:grid;gap:5px}button,input,select,a,textarea{font:inherit;padding:9px}button,a{min-height:44px}textarea{width:100%;min-height:210px;background:#071014;color:#f4f7f6}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.status{padding:10px;border-left:5px solid #46e0c1;background:#10242b}svg,canvas{max-width:100%;height:auto}.sr{position:absolute;left:-9999px}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;scroll-behavior:auto!important}}#work{min-height:90px;overflow-wrap:anywhere}</style><main><h1>PerformanceObserver interaction grouping lab</h1><p>One local pointer interaction can emit several Event Timing entries; the receipt groups them by positive interaction ID.</p><button id="trigger">Run the marked interaction</button><button id="reset">Reset observer</button><div id="work" class="panel" aria-live="polite"></div><p id="status" class="status">Waiting for one real interaction.</p><a id="downloadReceipt" download="inp-local-receipt.json">Export current JSON</a><textarea id="receipt" readonly aria-label="Execution receipt"></textarea></main><script>const receipt=document.getElementById('receipt'),statusNode=document.getElementById('status'),work=document.getElementById('work'),entries=[];let observer=null,markSequence=0;
function phases(entry){const inputDelay=Math.max(0,entry.processingStart-entry.startTime),processingDuration=Math.max(0,entry.processingEnd-entry.processingStart),presentationDelay=Math.max(0,entry.duration-inputDelay-processingDuration);return{inputDelay,processingDuration,presentationDelay,totalDuration:entry.duration,sumMatches:Math.abs(inputDelay+processingDuration+presentationDelay-entry.duration)<.001}}
function groupEntries(list){const positive=new Map(),unavailable=[];for(const entry of list){const normalized={name:entry.name,interactionId:Number(entry.interactionId)||0,startTime:entry.startTime,duration:entry.duration,processingStart:entry.processingStart,processingEnd:entry.processingEnd};if(normalized.interactionId>0){if(!positive.has(normalized.interactionId))positive.set(normalized.interactionId,[]);positive.get(normalized.interactionId).push(normalized)}else unavailable.push({...normalized,unavailableReason:'interactionId not exposed'})}const groups=[...positive.entries()].map(([interactionId,group])=>{const selected=[...group].sort((a,b)=>b.duration-a.duration)[0];return{interactionId,entryCount:group.length,eventNames:group.map(entry=>entry.name),selected,phases:phases(selected)}});return{status:groups.length?'observed':'unavailable',entryCount:list.length,groupedInteractionCount:groups.length,groups,unavailable}}
async function publish(){const interactionGrouping=groupEntries(entries),data={nondeterministic:true,timestamp:new Date().toISOString(),browser:navigator.userAgent,supportedEntryTypes:PerformanceObserver.supportedEntryTypes||[],observedEntries:entries.map(entry=>({name:entry.name,interactionId:Number(entry.interactionId)||0,startTime:entry.startTime,duration:entry.duration,processingStart:entry.processingStart,processingEnd:entry.processingEnd})),interactionGrouping,marks:performance.getEntriesByType('mark').filter(mark=>mark.name.startsWith('inp-fixture-')).map(mark=>({name:mark.name,startTime:mark.startTime})),boundary:'one local run is not CrUX field INP'};if(interactionGrouping.groups.some(group=>!group.phases.sumMatches||group.entryCount<1||group.selected.interactionId!==group.interactionId))throw Error('phase or grouping invariant failed');downloadReceipt.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));receipt.dataset.execution=JSON.stringify(data);receipt.value='PASS: '+JSON.stringify(data,null,2);statusNode.textContent=interactionGrouping.status==='observed'?'Grouped '+interactionGrouping.entryCount+' event entries into '+interactionGrouping.groupedInteractionCount+' interaction.':'This browser exposed no positive interaction ID; entries remain explicitly unavailable.'}
function start(){entries.length=0;performance.clearMarks();observer?.disconnect();const supported=(PerformanceObserver.supportedEntryTypes||[]).includes('event');if(supported){observer=new PerformanceObserver(list=>{entries.push(...list.getEntries());setTimeout(()=>void publish(),120)});observer.observe({type:'event',buffered:true,durationThreshold:0})}statusNode.textContent=supported?'Observer ready; activate the marked interaction.':'Event Timing entries unsupported; unavailable state will be exported.';void publish()}
trigger.addEventListener('pointerdown',()=>performance.mark('inp-fixture-pointer-'+(++markSequence)));trigger.addEventListener('click',()=>{performance.mark('inp-fixture-click-'+markSequence);const end=performance.now()+34;while(performance.now()<end){}work.replaceChildren(...Array.from({length:180},(_,index)=>{const node=document.createElement('span');node.textContent=index%12===0?' measured ':'.';return node}));requestAnimationFrame(()=>setTimeout(()=>void publish(),180))});reset.onclick=start;start();window.execute=start;window.groupEntries=groupEntries;</script></html>

Observe supported event entries honestly

Create a PerformanceObserver only when event is present in supportedEntryTypes, request an appropriate duration threshold, and catch construction or observe errors. Preserve name, startTime, duration, processingStart, processingEnd, interactionId when nonzero, and a safe target label when available.

Do not synthesize interaction IDs by grouping nearby clicks and key events. Browser grouping rules are part of Event Timing; when the exposed ID is zero or absent, the receipt can still show phase measurements but must say that an INP-like interaction group is unavailable.

PerformanceObserver INP instrumentation also needs lifecycle management. Disconnect observers when no longer needed, bound retained entries, redact targets, and avoid shipping verbose debugging to every user without a sampling and consent policy.

Debug Interaction to Next Paint by separating input delay, event-handler time, and presentation delay where evidence permits. The three phases suggest different next tools, but local estimates should not be promoted into fields that the executing browser never reported.

Interaction phase waveformA deterministic multi-entry fixture groups one positive interaction ID, selects its longest event, and divides that duration into three phases.input delay · 8 msprocessing · 41 mspresentation · 23 msselected click fixture · total 72 ms
Interaction phase waveform
A deterministic multi-entry fixture groups one positive interaction ID, selects its longest event, and divides that duration into three phases.
Deterministic interaction-group fixture
PhaseDurationDerivation
Input delay8 msprocessingStart − startTime
Processing41 msprocessingEnd − processingStart
Presentation23 ms72 − 8 − 41
Figure 1: The fixed group asserts pointerdown, pointerup, and click selection; the live browser run remains nondeterministic local evidence.

Calculate phase estimates from timestamps

Input delay is processingStart minus startTime, processing duration is processingEnd minus processingStart, and presentation delay can be estimated as startTime plus duration minus processingEnd when the fields are present. Keep raw timestamps beside derived values and reject negative or missing combinations.

The waveform in this article is an explicitly synthetic example of those formulas; the browser receipt carries whatever the executing session observed. This distinction prevents an editorial diagram from being mistaken for a field measurement.

PerformanceObserver INP debugging uses the largest phase to choose the next trace, not to prove a cause. Input delay suggests prior main-thread contention, processing suggests handler or rendering work, and presentation delay suggests post-handler rendering before next paint.

Slow interaction attribution starts with application marks around known work. A mark can prove that a synthetic sort or DOM expansion occurred inside the observed window, while a long-animation-frame entry or performance trace is needed to assign deeper script and rendering responsibility.

Correlate long tasks and application marks

Add performance marks at handler entry, important computation boundaries, state commit, and a post-paint checkpoint. Observe longtask entries where supported, then correlate by overlapping timestamps rather than claiming one entry caused another solely because it occurred nearby.

Use scheduler.yield for responsive long tasks when a long computation can be divided without breaking correctness. Yielding may reduce blocking while increasing total elapsed time, so compare interaction phases and product completion together.

The local lab records a measure around its controlled operation but does not expose a long-task observer in every browser. Missing support is a receipt field, not a zero-long-task conclusion.

A web performance observer can deliver several entries for one interaction. Group by interactionId, retain event name and duration, and choose the longest relevant entry for inspection; do not add event durations together and call the sum an INP candidate or a field percentile for the page. Preserve every raw entry so the grouping decision can be independently reviewed later.

Select a representative candidate carefully

Field INP uses a defined interaction-selection and sampling process, while a local debugging panel may simply list observed candidates. Follow the current web.dev INP guidance for the metric definition and threshold context; do not label the largest local entry “the site INP.”

PerformanceObserver INP tooling should show distribution, count, excluded entries, nonzero interaction IDs, and the selected local worst candidate. If only one action was run, say so; if an entry was unavailable because the page navigated or the observer started late, preserve that limitation.

Compare lab findings with field telemetry segmented by route and device class. A locally reproducible bottleneck can explain field pain, but a clean developer laptop does not disprove slower real-user conditions.

The controlled workload offers switches for synchronous computation, DOM growth, and rendering pressure. Their labels describe injected causes in this fixture only, letting the diagnostic sequence be tested without claiming that the same phase split explains an unrelated production interaction.

Production sampling must be bounded and privacy reviewed. Store stable action labels defined by the application rather than selectors containing user content, cap entry arrays, and sample sessions according to route risk and traffic. A slow interaction receipt should be useful without retaining keystrokes, form values, or DOM text. Document which targets are suppressed and how analysts distinguish suppression from missing browser support.

Open a trace before naming the cause

Capture a DevTools performance trace around the slow candidate and inspect task scheduling, event handler stacks, style recalculation, layout, paint, compositing, framework work, garbage collection, and background contention. The observer narrows when and which interaction; the trace reveals what occupied the browser.

For large input delay, inspect work that began before the event. For processing, inspect synchronous handler and consequent tasks; for presentation, inspect layout and paint after processingEnd, including hydration or rendering that your marks may not directly own.

PerformanceObserver INP evidence should state observed correlation rather than “X caused Y” until an intervention changes the predicted phase. Remove or defer one suspect, rerun the same fixture, and compare several trials.

PerformanceObserver INP debugging is local-browser evidence, while field INP comes from real-user aggregation with its own eligibility and sampling. PerformanceObserver INP debugging can guide a trace, but one exported run cannot replace CrUX or prove a site-wide Core Web Vitals result.

Candidate interaction distributionLocal durations surround the worst observed interaction while the unavailable field-data boundary remains separate.worst local candidatenot CrUX percentileexecution-specific sequence
Candidate interaction distribution
Local durations surround the worst observed interaction while the unavailable field-data boundary remains separate.
Observed candidates
Count emitted at execution
Worst local duration
Emitted only when an interaction ID is observed
Unsupported interaction IDs
Excluded and reported, never invented
Field boundary
No CrUX data collected or inferred
Figure 2: The live receipt reports what the executing browser exposed and refuses to invent a missing field aggregate.

Keep server and interaction latency separate

A click may wait on a server and still remain responsive if the UI yields, while a fast response can trigger expensive rendering. Use Server-Timing for server phases and Resource Timing or application marks for the request, then align them without folding network duration into Event Timing formulas.

PerformanceObserver INP diagnosis should identify whether the interface blocks during an awaited result, processes a large payload, or performs layout after arrival. A user-journey trace makes those boundaries visible while preserving the metric’s browser-side definition.

Connect logs with user journey context through correlation IDs, not private target text. Sampling and retention must match the sensitivity of the route.

The receipt is intentionally nondeterministic where truth requires it: timestamp, user agent, supported entry types, and measured durations come from execution. Deterministic fixture settings are stored separately so two runs can explain their configuration without pretending their timings should hash identically across machines, browsers, or power states.

Verify the web-vitals aggregation path

If the application reports INP with a library, pin and inspect the implementation rather than recreating a superficially similar percentile. Google’s web-vitals onINP source shows the maintained grouping and reporting logic used by that package.

Test page lifecycle, bfcache, hidden transitions, observer buffering, repeated interactions, and attribution bundle behavior in supported browsers. A custom debug panel can coexist with the library, but it should compare raw entries and library output without claiming exact parity unless tests establish it.

The article artifact deliberately stops short of calculating a site metric when interaction IDs are absent. That refusal is a feature: no green placeholder is safer than a plausible but fictional value.

Unexpected exceptions fail the lab loudly, whereas absent interactions produce an explicit no-candidate state. That distinction prevents a broken observer from generating a reassuring empty report and gives the tester a concrete path to retry after interacting with the marked control and reading the status region.

Validate the fix through a predicted phase. If chunking JavaScript is the hypothesis, processing duration or input delay should change while the same product outcome remains correct. If a rendering change targets presentation delay, inspect layout and paint together with the event entry. Regress accessibility, focus, and reduced motion after optimization, because a faster interaction that drops semantics is not a product win.

Ship a slow-interaction evidence packet

Capture route, interaction label, fixture, browser and version, viewport, hardware context, timestamp, supported entry types, raw event entries, derived phases, marks, trace link, field segment, suspected mechanism, intervention, before-and-after trials, and limitations. Keep screenshots and JSON together.

Run the browser lab several times at each controlled workload and expect values to vary. Verify its unavailable labels, export, focus, reduced-motion behavior, and error path, then move the same instrumentation pattern to one real interaction with a privacy review.

Revisit PerformanceObserver INP code when Event Timing, web-vitals, Core Web Vitals thresholds, or browser attribution fields change. The enduring workflow is evidence first: capture the action, decompose its observed phases, inspect the trace, and only then choose an optimization.

Refresh this tutorial when Event Timing, web-vitals candidate selection, thresholds, or attribution fields change. A three-phase waveform can introduce the method, but every share must call it a synthetic local capture and link back to the observer limitations and field-data boundary.

Slow-interaction diagnosis treeA branching tree maps delay, JavaScript, layout, paint, hydration, and contention to a next measurement.slow interactioninput delayscript / layoutpaint delayinspect contentionprofile tasksinspect rendering
Slow-interaction diagnosis tree
A branching tree maps delay, JavaScript, layout, paint, hydration, and contention to a next measurement.
  • Large input delay: inspect main-thread contention before the handler.
  • Large processing duration: profile handler, long tasks, style, and layout.
  • Large presentation delay: inspect rendering and next-paint blockers.
  • Unavailable attribution: capture a DevTools trace rather than naming a cause.
Figure 3: Phase evidence narrows the next measurement without pretending to prove causality.