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 — from startTime to 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 example pointerdown, pointerup and click of 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 any MutationObserver callbacks triggered by DOM changes in them.
  • Presentation delay = startTime + duration − processingEnd — style, layout, paint and any ResizeObserver callbacks in the rendering steps.

One Interaction's Three PhasesA timeline of a single tap. The input arrives while an IntersectionObserver callback task is still running, so the handler waits: input delay. The click handler runs: processing time, including a MutationObserver callback at its microtask checkpoint. The rendering steps run, including a ResizeObserver callback, before the next paint: presentation delay. The entry's duration spans all three.One tap, 184 ms durationmain threadIO callbackhandlers + MOrender + ROphaseinput delayprocessingpresentation0ms25ms50ms75ms100ms125ms150ms175ms200msnext paint

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

TypeScript
// 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

TypeScript
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.

From Event Entries to a Reported INPFive steps. Observe event entries with a forty millisecond threshold and buffered history. Skip entries without an interaction id. Keep the longest entry per interaction id, recording its phases and target. On page hide, sort interactions by duration and skip one per fifty as outliers. Report the chosen interaction with its breakdown.1Observetype event, durationThreshold 40, buffered true.2FilterIgnore entries whose interactionId is 0.3GroupLongest entry per interactionId, with phases and target.4PercentileOn hide: sort, skip one per 50 interactions.5ReportBeacon the page, duration and phase breakdown.

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 IntersectionObserver callback 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 MutationObserver callbacks 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 ResizeObserver callbacks 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.

Which Phase Points at Which ObserverA grid mapping the dominant INP phase to the observer most often responsible and what to check. Large input delay often comes from IntersectionObserver callbacks during scroll; check Long Animation Frame scripts. Large processing time can include MutationObserver callbacks at the handler's microtask checkpoint. Large presentation delay can include ResizeObserver callbacks in the rendering steps.Often caused byCheckInput delayIntersectionObserver batchLoAF scripts in that frameProcessing timeMutationObserver at checkpointRun Microtasks in handler taskPresentation delayResizeObserver in renderingLayout after RO callback

Verification Steps

  • Compare your computed INP with the web-vitals library 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 target descriptions 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. Use visibilitychange to hidden, 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.


↑ Back to PerformanceObserver & Rendering Metrics