Skip to the content

Concepts

Capture phase and listener order in Chromium, Firefox and WebKit

Why the tracker listens in the capture phase, how Chromium orders the pagehide listeners of window, and how the export phase keeps the last telemetry.

The DOM rule at the target

When an event gets to its target, the DOM standard starts the capture listeners of the target in the capture pass. Then it starts the other listeners of the target in the bubble pass. Thus a capture listener at the target starts before a bubble listener, also when the page added the bubble listener first.

The tracker uses this rule. It listens in the capture phase for visibilitychange, freeze, resume, pagehide and pageshow. Thus its subscribers record their values before an exporter that listens to the same event in the bubble phase.

Experiment E1: the listener order in three engines

The lag project measured the order in three engines (experiment E1). The experiment used Vitest browser mode with the Playwright provider. In each case, it added a bubble listener first and a capture listener second. Then it dispatched one event and recorded the listener that started first.

TargetChromium 145.0.7632.6 (headless)Firefox 146WebKit (Safari 26.0 build)
window, custom EventBubble (the order of registration)CaptureCapture
window, PageTransitionEvent("pagehide")Bubble (the order of registration)CaptureCapture
document, custom EventCaptureCaptureCapture
document, Event with bubbles: trueCaptureCaptureCapture
Element (document.body)CaptureCaptureCapture

What the results mean

visibilitychange, freeze and resume on document

The target of visibilitychange, freeze and resume is document. In the three engines, the capture listeners of the tracker start before the listeners of an exporter.

pagehide and pageshow on window

The target of pagehide and pageshow is window. There, Chromium starts the listeners in the order of their registration. Thus an exporter that added its pagehide listener before the tracker can flush before the monitors record their last values.

The capture phase cannot protect the last values in all engines. Thus the library has two other solutions.

  • The export phase. The exporter subscribes to the shared tracker with { phase: "export" }, and it has no pagehide listener of its own. In each transition, the tracker starts all observe subscribers, and then all export subscribers. Refer to the shared tracker and the phases.
  • handle(event). An exporter that keeps its own listener gives the event to handle(event) before it flushes. The tracker makes the transition at once, and its subscribers record their values. Later, the listener of the tracker ignores the same event object.

Watch a pagehide in each engine

The animation shows a pagehide in a page where the exporter added its listener before the tracker. It compares Chromium, Firefox and WebKit, and the export phase. It also does the window test of E1 in your browser.

All steps show.

Your browser: a test on window started the listeners in the order of registration, as Chromium did in experiment E1.

  1. Chromium 145Your browser

    The exporter added its pagehide listener first. Chromium starts the listeners of window in the order of registration.

    1. pagehide
    2. 1ExporterIts pagehide listener sends the batch.LCPCLSINP (not in the batch)
    3. 2TrackerIts capture listener: active to terminated.
    4. 3MonitorObserve subscriber: it records the final INP.

    The exporter sent the batch before the monitor recorded the final INP. The value is lost.

  2. Firefox 146 and WebKit

    The same page. These engines start the capture listener of the tracker first.

    1. pagehide
    2. 1TrackerIts capture listener: active to terminated.
    3. 2MonitorObserve subscriber: it records the final INP.
    4. 3ExporterIts pagehide listener sends the batch.LCPCLSINP

    The batch has the final INP.

  3. Any engine, with phase: "export"

    The exporter has no listener. It subscribes in the export phase, before the monitor subscribes.

    1. pagehide
    2. 1TrackerIts capture listener: active to terminated.
    3. 2MonitorObserve subscriber: it records the final INP.
    4. 3ExporterExport subscriber: it sends the batch.LCPCLSINP

    The batch has the final INP.

  4. Any engine, with handle(event)

    The exporter keeps its listener, and it starts first. It gives the event to the tracker before it sends.

    1. pagehide
    2. 1ExporterIts pagehide listener starts first. It calls handle(event).
    3. 2Trackerhandle(event): active to terminated.
    4. 3MonitorObserve subscriber: it records the final INP.
    5. 4ExporterIt sends the batch.LCPCLSINP
    6. 5TrackerIts capture listener: the event is already handled. No change.

    The batch has the final INP.

Each lane operates the real tracker on a model of window that starts the listeners in the order of experiment E1. The values are examples.

Why focus and blur are not capture listeners

The tracker listens for focus and blur on window, without the capture phase. A capture listener on window also gets the focus and blur events of each element of the page.

The tests in the library

A unit test makes sure that the listeners of the tracker are capture listeners, except for focus and blur. Another unit test adds a bubble listener to a simulated document first, and then makes the tracker. The test makes sure that the subscribers of the tracker start before that bubble listener.

page-lifecycle-tracker

A TypeScript library for the Page Lifecycle API, under the MIT license. It came from lag, a monitor of the lag of the main thread of the browser.

An AI model (Claude, from Anthropic) wrote most of the text and the code of this site, under the direction of the author. The tests and an STE linter examine them.