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
- Worst
eventduration perinteractionId - No
longtask -
first-input
| 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) |