Skip to the content

Lifecycle

Monitors the Page Lifecycle state of the page (active, passive, hidden, frozen, terminated) and counts the transitions, for the measurement conditions and the page-view vitals.

LifecycleStateMachine monitors the Page Lifecycle state of the page: active, passive, hidden, frozen or terminated. It counts each transition in lag_lifecycle_transitions.

The state machine is the npm package page-lifecycle-tracker. It came from lag. @mark1russell7/lag exports its class PageLifecycle as LifecycleStateMachine.

In a page, createBrowserDeps() gives the tracker that the page shares (getPageLifecycle()). An exporter can subscribe to the same tracker in the export phase. Then its subscribers start after the subscribers of the monitors, also at the end of the page in Chromium. The option sharedLifecycle: false gives the monitors their own tracker. The other monitors use it: the measurement conditions pause and validate with it, and the page-view vitals make their checkpoints with it. Page lifecycle explains the states and why the monitors pause.

What it measures

The signal is the number of lifecycle transitions, with the attributes from, to and trigger. The state machine also gives the current state (getState()) and a stream of transitions (subscribe()).

The monitor answers these questions: in which state was the page, and how frequently does the page change its state? A page in the background, in the back/forward cache or in a frozen tab gives no valid timer samples. Thus this state decides which samples of the other monitors are valid.

How it works

The state machine starts in hidden when document.visibilityState is hidden. Otherwise it starts in active if the document has the focus, and in passive if it does not. Then it changes its state on these events:

Show the diagram source
stateDiagram-v2
  direction LR
  [*] --> active
  [*] --> passive
  [*] --> hidden
  active --> passive: blur
  passive --> active: focus
  active --> hidden: visibilitychange
  passive --> hidden: visibilitychange
  hidden --> active: visibilitychange
  hidden --> passive: visibilitychange
  hidden --> frozen: freeze or pagehide (persisted)
  frozen --> hidden: resume
  frozen --> active: pageshow (persisted)
  frozen --> passive: pageshow (persisted)
  hidden --> terminated: pagehide
  terminated --> [*]
The transitions and their triggers. A pagehide event can occur in each state: with persisted, the page goes to frozen, otherwise to terminated. A freeze event also goes to frozen from each state. A pageshow event with persisted is a transition also from active or passive.
EventTargetPhaseTransition
focuswindowtargetpassive to active
blurwindowtargetactive to passive
visibilitychangedocumentcaptureactive or passive to hidden, or hidden to active or passive
freezedocumentcaptureto frozen
resumedocumentcaptureto hidden
pagehide with persistedwindowcaptureto frozen (into the back/forward cache)
pagehide without persistedwindowcaptureto terminated
pageshow with persistedwindowcaptureto active or passive (out of the back/forward cache)

These rules complete the table:

  • getState() reads document.visibilityState again. The comment of the code gives the cause: the browser changes visibilityState immediately, but it sends visibilitychange in a later task. Thus a timer callback between the two gets the state hidden.
  • The time of a transition is the timeStamp of the event, if it is more than 0 and not after performance.now(). Otherwise it is the current time. The comment of the code gives the cause: old browsers give timeStamp in Unix time.
  • terminated is the last state. A visibilitychange after it does not change it.
  • A pageshow with persisted is always a transition, also when the state is visible already. Chromium restores a page with resume, visibilitychange and then pageshow. Then the transition has the same from and to, for example active to active with the trigger pageshow. The comment of the code gives the cause: the subscribers must know about each restore.
  • The focus and blur listeners are not in the capture phase. The comment of the code gives the cause: a capture listener on window also gets the focus and blur events of each element.
  • The state machine has no beforeunload listener. The comment of the code gives two causes: the user can cancel that event, and the listener makes the page ineligible for the back/forward cache.
  • discarded is not a state. In a discarded page, no script operates. After the reload, the page-view vitals find it through document.wasDiscarded (the navigation type restore).

Capture listeners and the flush hook

The state machine uses capture listeners, so that its subscribers record their final values before an exporter that listens to the same event flushes. At the target of an event, the DOM standard puts the capture listeners before the other listeners. We measured the order in three engines (experiment E1). In each case, the page added a bubble listener first and a capture listener second:

TargetChromium 145Firefox 146WebKit (Safari 26.0 build)
window, custom Eventthe bubble listener firstthe capture listener firstthe capture listener first
window, pagehidethe bubble listener firstthe capture listener firstthe capture listener first
document, custom Eventthe capture listener firstthe capture listener firstthe capture listener first
document, Event with bubbles: truethe capture listener firstthe capture listener firstthe capture listener first
document.bodythe capture listener firstthe capture listener firstthe capture listener first

visibilitychange, freeze and resume have document as their target, thus the state machine is first in all three engines. pagehide and pageshow have window as their target. There, Chromium starts the listeners in the sequence in which the page added them. Thus an exporter that added its pagehide listener first can flush before the monitors record their final values.

For this reason, the monitors and otel-ts use the tracker that the page shares, in two phases. The monitors subscribe in the observe phase, and otel-ts flushes in the export phase. Thus each transition is in the flush that it starts.

Also connect AllMonitorHandles.flush() to the before-flush hook of the exporter, for example otel.onBeforeFlush(() => monitors.flush()) with otel-ts. Then each flush first records the values that wait for a checkpoint.

An exporter that does not use the shared tracker must give the page event: monitors.flush(event). The event is necessary for the end of the page. With the pagehide event, the state machine handles the event first (LifecycleStateMachine.handle()). Thus the transition to terminated, its event and the own hang report of the peer hang watch go into the last export. Later, the listener of the machine ignores the same event object. Without the event, the flush records only the Web Vitals.

A code review found this with the real SDK: in Chromium, the end of the page was not in the last export.

Marks

mark() and resolve(mark) give the transitions between a mark and the current time. The state machine keeps transitions only while a mark is open, and it removes the transitions before the oldest open mark. Thus resolve or cancel() each mark. summarizeTransitions() gives flags for a list of transitions, for example wasHidden and wasRestoredFromBFCache. The subscriptions do not use marks.

Browser support

FeatureChromiumFirefoxSafari
visibilitychange and document.visibilityStateyesyesyes
pagehide and pageshow with persistedyesyesyes
freeze, resume and document.wasDiscarded68not availablenot available

The research gives no versions for the rows with "yes". These facts come from browser support and clocks and timers:

  • Chromium freezes pages in the back/forward cache, in collapsed tab groups, and in hidden tabs that use much CPU (Energy Saver, desktop, from Chrome 133, in a gradual rollout).
  • On Android, Chromium freezes background pages and their workers after 5 minutes, and after 1 minute from Chrome 139.
  • Before Safari 14 and 14.1, visibilitychange did not bubble to window, and it did not occur when the user navigated away. The state machine has its visibilitychange listener on document, and it also uses pagehide.
  • No web event tells a page that the operating system sleeps. A locked screen usually makes the page hidden, but not on each platform.
  • A prerendered document is hidden until its activation.

Measurement validity

The state machine is the source of the page states for the measurement conditions:

  • createMeasurementConditions() opens an unreliable interval with the reason hidden or frozen for each period in which the page is not active or passive. The SampleValidator discards each sample that overlaps such an interval.
  • pauseWhileHidden() stops a monitor when the page leaves active or passive, and starts it again when the page comes back.
  • The page-view vitals make a checkpoint at each transition to hidden, frozen or terminated, and start a new page view at a pageshow with persisted.

Refer to measurement validity.

Metrics

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).

With an event sink, the factory also sends a lag.lifecycle.transition event for each transition, with from, to and trigger. The time of the event is the time stamp of the browser event. Thus a chart can show the transitions on the metrics, for example a hidden period or a restore from the back/forward cache. A restore gives an event also when the state does not change.

setupAllMonitors() adds lag.page_view.id. At a restore, the transition event has the ID of the page view that ends, because the new page view starts after the transition. The lag.page_view.start event of the new view comes next.

Configuration

function createInstrumentedLifecycle(
    deps : CoreDeps & LifecycleDeps & Partial<EventDeps> & Partial<AbsoluteClockDeps> & Partial<PerformanceDeps>,
) : MonitorHandle<LifecycleStateMachine>;

The factory uses CoreDeps (logger, clock, meter) and LifecycleDeps (document, window). It has no options. EventDeps is optional: without it, the factory sends no events. AbsoluteClockDeps (absoluteClock) and PerformanceDeps (performance) are optional: they give the events their times. Without both, the events get the time of the call. setupAllMonitors() makes the state machine first, thus its last-in, first-out stop stops the state machine last.

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

const deps = createBrowserDeps(window, { logger : console, meter : metrics.getMeter("lag") });
const lifecycle = createInstrumentedLifecycle(deps);
const unsubscribe = lifecycle.monitor?.subscribe(({ from, to, trigger, timestamp }) => {
    console.log(`${from} to ${to} (${trigger}) at ${timestamp.toFixed(0)} ms`);
});
console.log(lifecycle.monitor?.getState());

unsubscribe?.();
lifecycle.stop();

Cost

  • Listeners: seven event listeners on document and window.
  • Records: one counter value for each transition.
  • Memory: the transitions only while a mark is open.

Limits

  • The listener order on window in Chromium. The capture phase does not put the state machine first for pagehide and pageshow. Use the flush hook with the event of the flush. An exporter without such a hook loses the end of the page in Chromium.
  • No freeze in Firefox and Safari. There, the page becomes frozen only at a pagehide with persisted.
  • No sleep event. A sleep of the device without a hidden page gives no transition. The clock drift and worker monitors give that evidence.
  • The focus. Without document.hasFocus(), the state machine uses active for a visible page.
  • An error in a subscriber goes to the logger. The other subscribers still get the transition.

Tests

Unit tests:

  • The tests of the state machine are in the repository of page-lifecycle-tracker. They examine the states, the transitions on each event, marks and summarizeTransitions(). They also examine the subscriptions and their phases, handle(), the shared tracker and the capture phase of the listeners.
  • setup-all-monitors.test.ts, an exporter that gets pagehide first: without the event, the last export has no transition to terminated. With flush(event), it has the transition, and the listener of the machine does not count it again.
  • instrumented/lifecycle.test.ts: with a tracker that the page shares, the factory subscribes to it, and stop() does not dispose of it.
  • browser/browser-deps.test.ts: the shared tracker only for the global object, and none with sharedLifecycle: false.
  • instrumented/lifecycle.test.ts: the counter and the lag.lifecycle.transition event of each transition. The time of the event is the time stamp of the browser event. For an event without a time stamp, it is the time at which the machine handled the event. Without a clock, the events have no time. After stop(), no events.
  • setup-all-monitors.test.ts: the counter gets active to hidden and hidden to active, with the trigger visibilitychange. The transition event has the ID of the page view and a time of the absolute clock.

Browser tests (Vitest browser mode with Playwright, in Chromium, Firefox, WebKit and Chrome, unless an item names other browsers):

  • lifecycle.test.ts (a separate page, with synthetic events): the subscribers start before a freeze listener on document that the page added earlier. A pagehide with persisted gives the state frozen with the timeStamp of the event. dispose() removes each listener.
  • browser-apis.test.ts (only Chromium and Chrome): the browser has the freeze and resume events, and document.wasDiscarded is false.
  • cdp/freeze.test.ts (Chromium in the new headless mode): a real freeze of 1 s gives four transitions: to hidden, to frozen, to hidden and back to the visible state. Each transition has the time of its event, and a 10 ms interval starts fewer than 30 times during the freeze. With all monitors, lag_lifecycle_transitions counts the freeze and the resume.
  • cdp/visibility.test.ts (Chromium in the new headless mode): a second page in front gives the transition to hidden, and then to active or passive, with the trigger visibilitychange.

A CI job also operates the browser test files in Safari 26.6.2 on a GitHub macOS runner, through safaridriver. In the log of that job, lifecycle.test.ts passes.

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.