Skip to the content

Event Timing and INP

Records each interaction event of 16 ms or more from the Event Timing API, divided into its three phases, and calculates the INP of the page.

EventTimingMonitor records each interaction event of 16 ms or more from the Event Timing API. It divides each event into the input delay, the processing time and the presentation delay. It also calculates the Interaction to Next Paint (INP) of the page, from the start of the page.

What it measures

For each event entry with an interaction ID, the monitor records four values:

ValueCalculation
Durationduration: from the input to the next paint.
Input delayprocessingStart − startTime: the wait before the event handlers start.
Processing timeprocessingEnd − processingStart: the time of the event handlers.
Presentation delayduration − (processingEnd − startTime): the time from the end of the handlers to the next paint.

The monitor answers this question: how long does an interaction wait for the next paint, and which phase takes the time? A long input delay shows a busy main thread before the input. A long processing time shows slow handlers. A long presentation delay shows slow style, layout or paint work after the handlers.

The INP of the page view, with attribution, comes from the page-view vitals. This monitor gives the distribution of all slow events.

The playground plots the duration of each interaction against the blocking time of the long animation frames that overlap it. A slow interaction without a long frame has a different cause, for example a long presentation delay.

How it works

The monitor makes a PerformanceObserver for the entry type event, with durationThreshold: 16 and buffered: true. The browser default threshold is 104 ms, and 16 ms is the smallest permitted threshold of the specification. With the default, the monitor does not get most interactions.

The monitor ignores each entry without an interactionId (the value 0), for example hovers. The browser gives an interaction ID only to pointerdown, pointerup, click, keydown and keyup (browser support). Rounding can make the presentation delay negative, thus the monitor records it as at least 0. The factory also records the input delay and the processing time as at least 0.

The factory labels each value with the attribute interaction:

  • keyboard: an event name that starts with key.
  • pointer: pointer*, mouse*, touch*, click, auxclick and contextmenu.
  • other: all other names.

The INP of the page

InpCalculator keeps the 10 longest interactions. The events of one interaction (for example pointerdown, pointerup and click) share one interaction ID, and the longest event counts. INP is the longest interaction, with one outlier ignored for each 50 interactions. It is the interaction at the index min(n − 1, floor(count / 50)) of the sorted list, where n is the number of kept interactions. This is the rule of web-vitals (Web Vitals algorithms).

The count of interactions comes from performance.interactionCount, from the start of the page. The candidates also start at the start of the page, because the observer gets the buffered entries from before the start of the monitor. Thus the count and the candidates cover the same time, as for the load in web-vitals. Without performance.interactionCount, the calculator counts the interaction IDs that it got. getINP(), getInteractionCount() and getWorstInteractionDuration() give the current values. stop() clears the calculator, because a restart gets the buffered entries of the browser again.

Browser support

FeatureChromiumFirefoxSafari
event entries with durationThreshold85 (interface from 76)8926.2
interactionId9614426.2
performance.interactionCount14414426.2

The monitor uses only entries with an interactionId. Thus it records values from Chromium 96, Firefox 144 and Safari 26.2 (browser support). These facts of the API change the values:

  • The browser rounds duration to 8 ms.
  • Before the observer starts, the browser keeps only entries of 104 ms or more, in a buffer of 150 entries.
  • Chromium measures to the presentation of the frame. Firefox and Safari measure to the end of the render update. Thus, for the same work, Firefox and Safari give slightly lower values.
  • Safari gives high values in the tail. One cause is real: WebKit does not prioritize the render after an interaction over tasks that are already in the queue (WebKit bug 319911).
  • From Chrome 148, entry.target is null more frequently. From Chrome 150, a nested click (a label that forwards to a control) gets the interaction ID 0.

Measurement validity

The monitor does not pause while the page is hidden, and it uses no SampleValidator. The browser measures each event, and the monitor uses no timers.

Metrics

MetricKindUnitAttributesDescription
lag_event_duration_histogramHistogrammsinteractionThe duration of each interaction event of 16 ms or more, from input to the next paint.
lag_event_input_delay_histogramHistogrammsinteractionThe time from the input to the start of the event handlers.
lag_event_processing_histogramHistogrammsinteractionThe time that the event handlers used to process the event.
lag_event_presentation_delay_histogramHistogrammsinteractionThe time from the end of the event handlers to the next paint.

The monitor sends no events. The browser.web_vital event of the page-view vitals has the INP attribution.

Configuration

function createInstrumentedEventTiming(
    deps : CoreDeps & ObserverDeps & Partial<PerformanceDeps>,
) : MonitorHandle<EventTimingMonitor>;

The factory uses CoreDeps (logger, clock, meter) and ObserverDeps (PerformanceObserver). With PerformanceDeps (performance), the INP calculator reads performance.interactionCount. The factory has no options.

import { metrics } from "@opentelemetry/api";
import { createBrowserDeps, createInstrumentedEventTiming } from "@mark1russell7/lag";

const deps = createBrowserDeps(window, { logger : console, meter : metrics.getMeter("lag") });
if (deps.PerformanceObserver) {
    const eventTiming = createInstrumentedEventTiming({ ...deps, PerformanceObserver : deps.PerformanceObserver });
    // The INP of the page, from the start of the page
    console.log(eventTiming.monitor?.getINP(), eventTiming.monitor?.getInteractionCount());
}

Cost

  • Observers: one PerformanceObserver. No timers.
  • Records: four histogram values for each event entry of 16 ms or more with an interaction ID.
  • Memory: the 10 longest interactions.

Limits

  • Events, not interactions. One interaction gives one entry for each of its events. Thus a click can add three values (pointerdown, pointerup, click) to each histogram.
  • Only slow events. The browser gives no entry for an event of less than 16 ms. The histograms show the slow part of the distribution.
  • Not all input. Scroll, drag and hover are not discrete interactions, thus they have no interaction ID (Web Vitals algorithms).
  • The count without interactionCount. The count of interactions that the calculator got is too low, because fast interactions give no entry. Then INP can be slightly too high on a page with many fast interactions.
  • The page, not the page view. getINP() covers the time from the start of the page, also across back/forward cache restores. Before the start of the monitor, the buffer of the browser keeps only the entries of 104 ms or more. Thus only these earlier entries are candidates.
  • Engines differ. Do not compare the values of Chromium with the values of Firefox and Safari without the engine as a dimension.

Tests

Unit tests:

  • EventTimingMonitor.test.ts: the three phases of an event, the monitor ignores an entry with the interaction ID 0, and INP as the p98 of the interactions. The longest event of an interaction counts, and an interaction counts one time across its events. The observer uses a threshold of 16 ms. The monitor records a negative presentation delay as 0. It gets the buffered entries also when observe() delivers them at once, as old Safari did (WebKit bug 247863).
  • EventTimingMonitor.test.ts, the count: the calculator uses performance.interactionCount when the browser has it, and counts the interactions otherwise. The count starts at the start of the page, also after a restart, as the buffered candidates do.
  • InpCalculator.test.ts: a property test (fast-check) compares the calculator with a reference definition for all sequences of entries.

Browser test (Vitest browser mode with Playwright, in Chromium, Firefox, WebKit and Chrome):

  • lag-monitors.test.ts (only where the browser has Event Timing with interactionId): a real click on a button whose handler blocks for 120 ms gives a lag_event_duration_histogram value of 119 ms or more. The margin of 1 ms is for WebKit: its clock moves in steps of 1 ms, and it gave 119.99999999999955 ms one time.

Source

lag: Main-thread responsiveness monitoring for browser apps, exported as OpenTelemetry metrics.

To change a page, edit its file in packages/site/content/. The writing style guide tells you how.

An AI model (Claude, from Anthropic) wrote most of the text and the code of this site and of the library, under the direction of the author. The tests and the STE linter examine them. The writing standard gives the reason for this note.