Skip to the content

Page lifecycle

The lifecycle states that the library monitors, how it reads them, and how the final values of a page view get to the exporter before the page goes away.

LifecycleStateMachine monitors the Page Lifecycle state of the page. The lifecycle monitor gives its configuration and its tests. Other parts of the library use the state. The measurement conditions discard the samples of hidden and frozen periods, and the timer-driven monitors pause in these periods. The page-view vitals record their values at each change to a state that is not visible. setupAllMonitors() registers the state machine first, thus it stops last.

The states

StateMeaning
activeThe page is visible, and it has the focus.
passiveThe page is visible, but it does not have the focus.
hiddenThe value of document.visibilityState is hidden.
frozenThe browser stopped the tasks of the page, for example in the back/forward cache.
terminatedThe page unloads: a pagehide event came, and its persisted property is false.

The function isVisibleState() gives true for active and passive. The monitors treat these two states in the same way.

At the start, the state is hidden if document.visibilityState is hidden. If not, the state is active when document.hasFocus() is true, and passive when it is false.

The state machine has no "discarded" state. A discarded page does no script, thus the library can find a discard only after the reload, through document.wasDiscarded. The page view of that load gets the navigation type restore.

The transitions

Show the diagram source
stateDiagram-v2
    direction LR
    state "visible: active or passive" as visible
    state visible {
        [*] --> active: focus
        [*] --> passive: no focus
        active --> passive: blur
        passive --> active: focus
    }
    [*] --> visible
    [*] --> hidden: visibilityState is hidden
    visible --> hidden: visibilitychange
    hidden --> visible: visibilitychange
    hidden --> frozen: freeze
    frozen --> hidden: resume
    visible --> frozen: pagehide, persisted
    hidden --> frozen: pagehide, persisted
    frozen --> visible: pageshow, persisted
    visible --> terminated: pagehide, not persisted
    hidden --> terminated: pagehide, not persisted
    terminated --> [*]
In the code, freeze, resume, pagehide and pageshow change the state from each state. The diagram shows their usual source states.
EventTargetPhaseTransition
focuswindowTargetFrom passive to active
blurwindowTargetFrom active to passive
visibilitychangedocumentCaptureFrom a visible state to hidden, or from hidden to a visible state
freezedocumentCaptureTo frozen
resumedocumentCaptureTo hidden
pagehidewindowCaptureTo frozen if persisted is true, and to terminated if it is false
pageshowwindowCaptureTo a visible state, only if persisted is true. It is always a transition, also from a visible state.

A visibilitychange event does not change the frozen state or the terminated state. Thus a page that gets pagehide and then visibilitychange stays in the terminated state.

A pageshow event with persisted set to true gives a transition also when the state does not change. Then the transition has the same from and to, for example active to active. Thus the subscribers get a transition for each restore from the back/forward cache.

The state machine does not listen for beforeunload. A script can cancel that event, and then a live page has the state terminated. Such a listener can also prevent the back/forward cache.

Each transition has the properties from, to, trigger and timestamp. The state machine gives each transition to each subscriber. If an error occurs in one subscriber, the state machine records the error in the log and continues with the other subscribers.

import { createBrowserDeps, createNoopMeter, setupAllMonitors } from "@mark1russell7/lag";

const monitors = setupAllMonitors(createBrowserDeps(window, {
    logger : { log : (level, message) => console.log(level, message) },
    meter : createNoopMeter(),
}));

// Show each transition, with the time of its event
const unsubscribe = monitors.lifecycleStateMachine?.subscribe(({ from, to, trigger, timestamp }) => {
    console.log(`${from} to ${to} (${trigger}) at ${timestamp.toFixed(1)} ms`);
});

A new read of visibilityState

The browser changes document.visibilityState immediately, but it dispatches the visibilitychange event in a separate task. A timer callback can start between the two. Thus getState(), mark() and resolve() read document.visibilityState again each time. If the value is different, the state machine makes the transition at that time, with the trigger visibilitychange.

This read is important for the sample validator. The validator reads the state before it examines a sample. Thus a sample whose window stops after the change of visibilityState overlaps the hidden interval, also before the event comes. A unit test of setupAllMonitors() changes visibilityState without the event, and it makes sure that lag_samples_discarded counts a sample with the cause hidden.

Capture-phase listeners

The state machine listens in the capture phase for visibilitychange, freeze, resume, pagehide and pageshow. At the target of an event, the DOM standard starts the capture listeners before the other listeners. Thus the subscribers of the state machine record their values before an exporter that listens to the same event in the bubble phase. For example, the final Web Vitals of a page view get into the export of that page view.

The focus and blur listeners are not in the capture phase. A capture listener on window also gets the focus and blur events of each element of the page.

We measured the sequence of the listeners in three engines (local experiments, experiment E1). In each case, a bubble listener was added first and a capture listener second. The table shows the listener that started first:

TargetChromium 145 (headless)Firefox 146WebKit (Safari 26.0 build)
window, a custom EventBubble (the sequence of registration)CaptureCapture
window, PageTransitionEvent("pagehide")Bubble (the sequence of registration)CaptureCapture
document, a custom EventCaptureCaptureCapture
document, a bubbling EventCaptureCaptureCapture
An element (document.body)CaptureCaptureCapture

The events visibilitychange, freeze and resume have document as their target. In the three engines, the capture listeners of the state machine start before the listeners of an exporter. The events pagehide and pageshow have window as their target. In Chromium, the listener that the page added first starts first. Thus an exporter that listens to pagehide before the monitors can flush before the monitors record their final values.

The sequence of the listeners cannot protect the final values in all engines. Two parts solve this problem: a lifecycle tracker that the page shares, and a flush hook.

The shared tracker

The state machine is the npm package page-lifecycle-tracker. In a page, createBrowserDeps() gives the monitors the tracker that the page shares (getPageLifecycle()). Each script of the page that uses getPageLifecycle() gets the same tracker, also from a different copy of the package.

A subscriber has a phase: observe (the default) or export. For each transition, the tracker starts all subscribers of the observe phase first, and then the subscribers of the export phase. The monitors subscribe in the observe phase. The otel-ts library subscribes in the export phase, and it flushes there. Thus the last export has the transition to terminated, its event and the own hang report of the peer hang watch. The sequence of the scripts is not important.

The flush hook

AllMonitorHandles.flush() records the values that the monitors keep until a checkpoint. At this time, these values are the Web Vitals of the current page view. The function makes a checkpoint of the page-view vitals that is not final.

The onBeforeFlush(listener) function of otel-ts starts each listener synchronously before each flush. The flushes include each page hide, the stop at the end of the page, each use of forceFlush() and the first use of shutdown(). Connect the two:

import { init } from "@mark1russell7/otel-ts";
import { createBrowserDeps, createOtelLoggerAdapter, setupAllMonitors } from "@mark1russell7/lag";

const otel = init({ serviceName : "shop" });
const monitors = setupAllMonitors(createBrowserDeps(window, {
    meter : otel.getMeter("lag"),
    logger : createOtelLoggerAdapter(otel.getLogger("lag")),
}));

// Record the values of the page view before each export
otel.onBeforeFlush(() => monitors.flush());

With the hook, the export contains the values in all engines, and the sequence of the listeners is not important. A unit test of setupAllMonitors() makes sure that flush() records the pending Web Vitals.

With a different exporter, use monitors.flush() in the same way before each flush of the exporter. If the exporter does not use the shared tracker, give flush() the page event that started the flush: monitors.flush(event). Then the state machine handles the event first (handle()), and its own listener ignores the same event object later.

Show the diagram source
sequenceDiagram
    participant B as Browser
    participant T as Shared lifecycle tracker
    participant M as Monitors
    participant O as otel-ts
    B->>T: pagehide (capture listener)
    Note over T: phase observe
    T->>M: transition to terminated
    M->>M: record the transition, the event and the hang report of the page
    Note over T: phase export
    T->>O: transition to terminated
    O->>M: onBeforeFlush: monitors.flush()
    M->>O: record the values of the page view
    O->>O: export the telemetry and stop
The shared tracker starts the subscribers of the observe phase first. otel-ts flushes in the export phase, after the monitors recorded the end of the page. The hook records the values of the page view before the export.

The time of a transition

The timestamp of a transition is the timeStamp of its event, if the value is applicable. A value is applicable if it is a number above 0 and not after the current time of the clock. If not, the state machine uses the current time. The code comment gives the cause: earlier browsers gave Unix time in timeStamp.

createBrowserDeps() gives performance.now() as the clock, and event.timeStamp has the same time base. Thus the transition has the time when the browser made the event, not the later time when the listener started. A transition from a read of visibilityState has no event, thus it has the time of the read. A browser test dispatches a pagehide event, and it makes sure that the timestamp of the transition is the timeStamp of the event.

The page-view vitals use these times. The time of the first transition to a state that is not visible stops the load metrics of the first page view. The time of a pageshow event is the start of a page view that comes from the back/forward cache.

Back/forward cache and freeze

A page that goes into the back/forward cache gets pagehide with persisted set to true. The state machine changes to frozen. When the browser restores the page, the page gets pageshow with persisted set to true. The state machine changes to active or passive, and the page-view vitals start a new page view. All three engines have the persisted property (browser support).

Chromium restores a page with resume, visibilitychange and then pageshow. Thus the state is already visible when pageshow comes. The state machine still gives a transition with the trigger pageshow, and the page-view vitals start the new page view at that time. A unit test makes sure that this event sequence of Chromium gives the transition.

Only Chromium has the freeze and resume events, from version 68. Firefox and Safari do not have them. Chrome freezes a page in these conditions (browser support):

  • The page goes into the back/forward cache.
  • The tab is in a collapsed tab group.
  • Energy Saver freezes a hidden tab that uses much CPU time. This feature is for desktop computers, from Chrome 133, with a gradual rollout.
  • On Android, Chrome freezes background pages and their workers after 5 minutes, and after 1 minute from Chrome 139.

A frozen page does no tasks. The Page Lifecycle specification blocks the dedicated workers of a frozen document. In desktop Chromium, this rule for workers is behind a flag at this time. Thus the timers of a worker can continue while the page is frozen.

Do not use the unload event to send data. From Chrome 154, Chrome does not start unload listeners (browser support). otel-ts does not listen to beforeunload or unload.

How the monitors use the state

  • The measurement conditions open an interval with the cause hidden or frozen for each period in a state that is not visible. They discard each sample that overlaps such an interval.
  • pauseWhileHidden() stops a monitor when the state changes from visible to not visible, and starts it again on the change back. DriftLag, MacrotaskLag, frame timing, idle availability, scheduling fairness, worker lag and shared-memory liveness pause in this way.
  • The timer-throttle detector does not pause, because it must find the throttling of a hidden page. The clock-drift monitor does not pause, because the device can sleep while the page is hidden.
  • The page-view vitals make a checkpoint at each change to hidden, frozen or terminated. The checkpoint at terminated is final. A pageshow with persisted set to true starts a new page view.

The transition metric

MetricKindUnitAttributesDescription
lag_lifecycle_transitionsCounter{transition}from, to, triggerThe number of page lifecycle transitions. A restore from the back/forward cache always counts, with the trigger pageshow, also when the state does not change (Chromium makes the page visible before pageshow).

The counter has one series for each combination of from, to and trigger that occurs. This query gives the transitions of the last hour, for all pages:

sum by (from, to, trigger) (increase(lag_lifecycle_transitions[1h]))

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.