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.
[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.
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.
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.
Related Guides
- Debugging CLS with the Layout Instability API — the sibling Core Web Vital, measured through its own PerformanceObserver entry type.
- Observing Long Tasks with PerformanceObserver — long tasks are the main driver of input delay in INP.
- Reading the DevTools Performance Flame Chart — visually confirm which phase of an interaction is over budget.
- Core Web Vitals Measurement — the parent guide covering LCP, CLS, and INP field measurement.