Measuring INP with the Event Timing API

Interaction to Next Paint is measured through the Event Timing API — the event and first-input entry types delivered by PerformanceObserver — which exposes interactionId to group related events and a duration that spans the three INP phases: input delay, processing, and presentation delay. This page is part of Core Web Vitals Measurement, within Rendering Performance Metrics and Tooling.

The Three Phases of an Interaction

An interaction’s latency is the gap from the user’s gesture to the next frame the browser presents in response. The Event Timing API splits that single duration into three measurable phases:

  • Input delay — from the hardware event until the handler starts. Inflated when the main thread is busy with a long task and cannot dispatch the event.
  • Processing time — the event handlers running. Inflated by heavy synchronous work, especially forced layout reads.
  • Presentation delay — from the handlers finishing until the browser paints the next frame. Inflated by expensive style, layout, or paint work the handler dirtied.
INP interaction split into three phases A single 264ms interaction duration divided into input delay, processing time, and presentation delay along a timeline. event.duration = 264ms (INP candidate) Input delay — 158ms main thread stuck in a long task Processing — 74ms handlers run, layout thrash Present 32ms 2 frames late hardware event (pointerup) next paint 1 frame = 16.6ms budget
[Event Timing — one slow tap, 16.6ms frame budget]
event (pointerup)  startTime: 1820  duration: 264ms  — INP candidate
│
├─ input delay        158ms  ── main thread in a 0.16s long task
├─ processing          74ms  ── handler called getBoundingClientRect() in a loop
└─ presentation delay  32ms  ── two frames late (32ms ≈ 2 × 16.6ms)

Capturing event Entries

A raw event entry fires for many low-level events; INP only counts ones that belong to a discrete interaction, which the browser tags with a non-zero interactionId. Group entries by that id and take the longest duration per interaction, then report the worst (technically the 98th-percentile) interaction as the page’s INP.

// ✅ Observe 'event' + 'first-input', group by interactionId
const interactions = new Map()

const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.interactionId) continue // skip events not part of an interaction
    const prev = interactions.get(entry.interactionId) || 0
    // an interaction can emit several events (pointerdown/up, click)
    interactions.set(entry.interactionId, Math.max(prev, entry.duration))
  }
})
// durationThreshold:16 reports only events at/over one frame; first-input has no threshold
po.observe({ type: 'event', buffered: true, durationThreshold: 16 })
po.observe({ type: 'first-input', buffered: true })

durationThreshold must be 16 or higher (the spec floor) and at most you want it near one frame so you do not drop borderline-slow interactions. The first-input type is observed separately because it reports the very first interaction even when its duration is under the threshold — the first tap is frequently the slowest due to hydration.

Grouping raw event entries by interactionId Several event entries sharing one interactionId are collapsed to the maximum duration, then the worst interaction becomes the page INP. raw event entries pointerdown id=7 42ms pointerup id=7 74ms click id=7 264ms click id=9 118ms max() per interactionId id=7 → 264ms id=9 → 118ms INP worst = 264ms

Minimal Reproduction

This handler reliably produces a slow INP because it reads layout synchronously inside the event, forcing a synchronous layout flush before the next paint.

// ❌ Slow: handler dirties the DOM then reads it back, forcing layout in-handler
button.addEventListener('click', () => {
  for (const card of cards) {
    card.classList.add('expanded')             // write: invalidates layout
    const h = card.getBoundingClientRect().height // read: forces synchronous layout
    card.style.setProperty('--h', `${h}px`)    // write: invalidates again → thrash
  }
})

Each read after a write re-runs layout for the whole dirtied subtree, inflating processing time and pushing presentation delay past the frame budget. The Event Timing entry shows the cost in its processing span.

The Fix

Yield to let the browser paint the response first, and batch reads separately from writes so layout runs once. Measuring layout-bound work and moving the unrelated work off the critical path keeps the interaction inside one or two frames.

// ✅ Fast: paint the visual response, then defer non-urgent work
button.addEventListener('click', () => {
  cards.forEach((c) => c.classList.add('expanded')) // batched writes only

  // let the browser present the next paint before doing more work
  requestAnimationFrame(() => {
    // read phase: all measurements together, layout computed once
    const heights = cards.map((c) => c.getBoundingClientRect().height)
    // write phase: apply, no interleaved reads
    cards.forEach((c, i) => c.style.setProperty('--h', `${heights[i]}px`))
  })
})

Separating the read and write phases eliminates the per-card layout thrash, collapsing processing time, and yielding before the heavy work shrinks presentation delay so the interaction lands within the 16.6ms budget. For the broader read/write batching pattern see How to Batch DOM Reads and Writes to Prevent Thrashing.

Before and after yielding to paint The slow path interleaves reads and writes inside one long handler; the fast path paints first then defers measurement to the next frame. Before — thrash in-handler write → read → write × N (layout each loop) late paint 264ms After — paint, then defer batched writes paint rAF: read all, write all < 50ms

Verification

Metric Target How measured
INP (p75) < 200ms max event duration per interactionId
Input delay < 50ms processingStart - startTime
Processing time < 100ms processingEnd - processingStart
Presentation delay ≤ 16.6ms duration - (processingEnd - startTime)

The Three Phases the API Exposes

The Event Timing API is valuable precisely because it splits an interaction into the three phases where latency can hide, and each points at a different fix. Input delay — the gap between the event arriving and your handler starting — almost always means the main thread was busy with unrelated work when the user acted, so the fix is to break up or defer that work. Processing time — how long your handlers run — points at the handler itself: an expensive computation or a synchronous layout read inside it. Presentation delay — the time from the handlers finishing to the next paint — balloons when the DOM change your handler made forces a heavy style, layout, or paint before the frame can render.

Computing these from an event entry is a matter of subtracting the timestamps the API provides: input delay is processingStart − startTime, processing is processingEnd − processingStart, and presentation is startTime + duration − processingEnd. Reporting which of the three dominates for your worst interactions is what makes INP actionable rather than merely a failing grade — two pages with the same INP can need opposite fixes, and only the phase breakdown tells you which. Capturing that attribution in the field, keyed by interaction target, turns a slow number into a specific handler to optimise.

Frequently Asked Questions

Why do I only see event entries with a non-zero interactionId in my INP calculation?

INP counts only discrete interactions — taps, clicks, and key presses — that the browser tags with a non-zero interactionId. Continuous events like pointermove or scroll fire many event entries with interactionId of 0, and including them would flood your data with non-interactions. Skipping the zero-id entries, as the grouping loop does with if (!entry.interactionId) continue, keeps the metric aligned with the spec.

Why does one interaction emit several event entries?

A single tap dispatches pointerdown, pointerup, and click, and each is reported as its own event entry sharing one interactionId. INP is defined as the longest of them, so you take Math.max across every entry in the group rather than summing or averaging. Keying a Map by interactionId collapses the group to that maximum duration.

Why is durationThreshold capped at a low value instead of set higher for less noise?

durationThreshold has a spec floor of 16ms and controls which event entries the observer delivers. Setting it much higher silently drops borderline-slow interactions in the 40–150ms range that still degrade p75 INP, so you would under-report. Keep it at or just above one frame and do the percentile filtering in your own aggregation logic instead.

Why observe first-input separately when event already covers interactions?

The first-input entry type reports the very first interaction on the page even when its duration falls under durationThreshold, so it is not dropped by the threshold that event uses. The first tap is frequently the slowest because it collides with hydration and other startup work, which is exactly the interaction you most want to capture.