Observe event entries with durationThreshold: 40 and buffered: true, group them by interactionId, keep the longest duration per interaction, and report a high percentile of those per page as INP — splitting each worst interaction into input delay, processing time and presentation delay so you know what to fix.
Problem / Scenario Context
A team sees "INP: poor" in their Core Web Vitals report but cannot reproduce slow interactions in the lab. They need field data that says which interaction is slow, on which page, and which phase of it — input delay (the main thread was busy, often with observer callbacks), processing (their handlers), or presentation (rendering after the handler). The web-vitals library gives a number; they want to understand how that number is built so they can extend it with their own attribution.
The Event Timing API, read through a PerformanceObserver, is the source of INP. The PerformanceObserver & Rendering Metrics topic covers the observer; this page builds INP from it.
Mechanics Explanation
The browser creates a PerformanceEventTiming entry for discrete input events (pointer, key, click) whose duration exceeds the observer's durationThreshold (minimum 16 ms, default 104 ms). Each entry has:
startTime— when the input occurred (the event's timestamp).processingStart/processingEnd— when the first handler started and the last one finished.duration— fromstartTimeto the next paint after processing, rounded to 8 ms.interactionId— a non-zero id shared by all events that belong to one user interaction (for examplepointerdown,pointerupandclickof one tap).
INP is defined per page visit as (approximately) the worst interaction duration, ignoring one outlier for every 50 interactions — effectively the 98th percentile for pages with many interactions. Each interaction's duration is the maximum duration among its entries.
From each entry, three phases fall out:
- Input delay =
processingStart − startTime— main thread busy with something else. - Processing time =
processingEnd − processingStart— event handlers, including anyMutationObservercallbacks triggered by DOM changes in them. - Presentation delay =
startTime + duration − processingEnd— style, layout, paint and anyResizeObservercallbacks in the rendering steps.
Comparison Table: Event Timing Entry Fields
| Field | Meaning | Use |
|---|---|---|
name |
event type, e.g. pointerdown, click, keydown |
label the interaction |
interactionId |
shared id for one interaction; 0 if not an interaction | group entries |
startTime |
input timestamp | phase arithmetic |
processingStart |
first handler start | input delay |
processingEnd |
last handler end | processing time |
duration |
start to next paint, 8 ms granularity | interaction latency |
target |
event target element (may be null if removed) | attribution |
Minimal Reproducible Example
// Naive: logs every event entry, ungrouped, missing interactions before registration.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) console.log(e.name, e.duration);
}).observe({ type: 'event' });
It logs pointerdown, pointerup and click separately for one tap, misses anything before the observer was registered, and ignores entries under 104 ms, the default threshold.
Production-Safe Solution
interface Interaction {
id: number;
duration: number;
name: string;
target: string;
inputDelay: number;
processing: number;
presentation: number;
}
const interactions = new Map<number, Interaction>();
let count = 0;
function describe(el: Node | null): string {
if (!(el instanceof Element)) return '(removed)';
return el.id ? `#${el.id}` : `${el.localName}${el.classList.length ? '.' + [...el.classList].join('.') : ''}`;
}
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries() as PerformanceEventTiming[]) {
if (!e.interactionId) continue; // not part of an interaction
const prev = interactions.get(e.interactionId);
if (!prev) count++;
if (prev && prev.duration >= e.duration) continue; // keep the longest entry
interactions.set(e.interactionId, {
id: e.interactionId,
duration: e.duration,
name: e.name,
target: describe(e.target),
inputDelay: e.processingStart - e.startTime,
processing: e.processingEnd - e.processingStart,
presentation: e.startTime + e.duration - e.processingEnd,
});
}
});
po.observe({ type: 'event', durationThreshold: 40, buffered: true } as PerformanceObserverInit);
function currentINP(): Interaction | undefined {
const sorted = [...interactions.values()].sort((a, b) => b.duration - a.duration);
const skip = Math.min(sorted.length - 1, Math.floor(count / 50)); // one outlier per 50
return sorted[skip];
}
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
po.takeRecords(); // include the latest entries
const inp = currentINP();
if (inp) navigator.sendBeacon('/rum/inp', JSON.stringify({ page: location.pathname, ...inp }));
});
buffered: true delivers entries that occurred before the observer registered (limited to those above the threshold). durationThreshold: 40 keeps enough interactions to compute a percentile without collecting trivial ones. Counting all interactions — not just the stored ones — keeps the outlier skip accurate. takeRecords() before reporting ensures the final interaction on the page is included.
The count above only counts interactions that produced an entry above the threshold, so it underestimates the true number; where the performance.interactionCount property is available, use it instead for the outlier skip.
Attributing Slow Interactions to Observers
The phase breakdown tells you where to look:
- Large input delay means something else held the main thread when the input arrived. On observer-heavy pages that is frequently an
IntersectionObservercallback processing a scroll batch. Pair this data with Long Animation Frame attribution, which names the scripts that ran in the frame. - Large processing time is your handlers — plus any
MutationObservercallbacks triggered by the DOM changes they made, which run at the end of the handler's task. - Large presentation delay is rendering: style and layout of what the handler changed, and any
ResizeObservercallbacks in those rendering steps.
The fix strategies for the first case — yielding, deferring and prioritising observer work — are in keeping observer callbacks under the INP budget.
Verification Steps
- Compare your computed INP with the
web-vitalslibrary on the same pages; they should agree closely. - Tap during a heavy scroll in the lab and confirm the reported interaction has a large input delay.
- Check that the last interaction is reported by tapping and then immediately switching tabs.
- Look at the
targetdescriptions in field data to see which controls are slow. - Segment by device class; INP is almost always driven by low-end devices.
Common Mistakes to Avoid
- Treating each event entry as an interaction. Group by
interactionId. - Using the default 104 ms threshold for percentile maths. You will not see enough interactions.
- Reporting on
unload. Usevisibilitychangetohidden, which fires reliably on mobile. - Averaging interactions. INP is a high percentile, not a mean.
FAQ
What is interactionId?
A number the browser assigns to all event entries that belong to the same user interaction, such as the pointerdown, pointerup and click of one tap. Entries unrelated to interactions, such as mouseover, have an interactionId of 0.
Why is duration rounded to 8 milliseconds?
To limit the precision of timing data exposed to pages, as a privacy and security measure. It is fine for a metric where the thresholds are 200 and 500 ms.
Does buffered: true give me every interaction since page load?
It gives buffered event entries above the default threshold that occurred before registration. Register the observer as early as possible, in the head, to capture everything with your chosen threshold.
Why ignore one interaction per fifty?
The definition of INP excludes the single worst interaction for every 50 on the page so that one freak outlier on a long-lived page does not define the metric. For pages with fewer than 50 interactions, INP is the worst one.
Do observer callbacks ever appear as interactions?
No. Only user input events create event timing entries. Observer callbacks affect interactions by delaying their handlers or their next paint.
Related
- Observing Long Animation Frames (LoAF) — attributing the delay
- PerformanceObserver Buffered Entries Explained — what buffered means
- Keeping Observer Callbacks Under the INP Budget — fixing observer-caused delay
↑ Back to PerformanceObserver & Rendering Metrics