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 --> [*]| Event | Target | Phase | Transition |
|---|---|---|---|
focus | window | target | passive to active |
blur | window | target | active to passive |
visibilitychange | document | capture | active or passive to hidden, or hidden to active or passive |
freeze | document | capture | to frozen |
resume | document | capture | to hidden |
pagehide with persisted | window | capture | to frozen (into the back/forward cache) |
pagehide without persisted | window | capture | to terminated |
pageshow with persisted | window | capture | to active or passive (out of the back/forward cache) |
These rules complete the table:
getState()readsdocument.visibilityStateagain. The comment of the code gives the cause: the browser changesvisibilityStateimmediately, but it sendsvisibilitychangein a later task. Thus a timer callback between the two gets the statehidden.- The time of a transition is the
timeStampof the event, if it is more than 0 and not afterperformance.now(). Otherwise it is the current time. The comment of the code gives the cause: old browsers givetimeStampin Unix time. terminatedis the last state. Avisibilitychangeafter it does not change it.- A
pageshowwithpersistedis always a transition, also when the state is visible already. Chromium restores a page withresume,visibilitychangeand thenpageshow. Then the transition has the samefromandto, for exampleactivetoactivewith the triggerpageshow. The comment of the code gives the cause: the subscribers must know about each restore. - The
focusandblurlisteners are not in the capture phase. The comment of the code gives the cause: a capture listener onwindowalso gets thefocusandblurevents of each element. - The state machine has no
beforeunloadlistener. 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. discardedis not a state. In a discarded page, no script operates. After the reload, the page-view vitals find it throughdocument.wasDiscarded(the navigation typerestore).
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:
| Target | Chromium 145 | Firefox 146 | WebKit (Safari 26.0 build) |
|---|---|---|---|
window, custom Event | the bubble listener first | the capture listener first | the capture listener first |
window, pagehide | the bubble listener first | the capture listener first | the capture listener first |
document, custom Event | the capture listener first | the capture listener first | the capture listener first |
document, Event with bubbles: true | the capture listener first | the capture listener first | the capture listener first |
document.body | the capture listener first | the capture listener first | the 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
| Feature | Chromium | Firefox | Safari |
|---|---|---|---|
visibilitychange and document.visibilityState | yes | yes | yes |
pagehide and pageshow with persisted | yes | yes | yes |
freeze, resume and document.wasDiscarded | 68 | not available | not 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,
visibilitychangedid not bubble towindow, and it did not occur when the user navigated away. The state machine has itsvisibilitychangelistener ondocument, and it also usespagehide. - 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 reasonhiddenorfrozenfor each period in which the page is notactiveorpassive. TheSampleValidatordiscards each sample that overlaps such an interval.pauseWhileHidden()stops a monitor when the page leavesactiveorpassive, and starts it again when the page comes back.- The page-view vitals make a checkpoint at each transition to
hidden,frozenorterminated, and start a new page view at apageshowwithpersisted.
Refer to measurement validity.
Metrics
| Metric | Kind | Unit | Attributes | Description |
|---|---|---|---|---|
lag_lifecycle_transitions | Counter | {transition} | from, to, trigger | The 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
documentandwindow. - Records: one counter value for each transition.
- Memory: the transitions only while a mark is open.
Limits
- The listener order on
windowin Chromium. The capture phase does not put the state machine first forpagehideandpageshow. 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
freezein Firefox and Safari. There, the page becomesfrozenonly at apagehidewithpersisted. - 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 usesactivefor 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 andsummarizeTransitions(). 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 getspagehidefirst: without the event, the last export has no transition toterminated. Withflush(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, andstop()does not dispose of it.browser/browser-deps.test.ts: the shared tracker only for the global object, and none withsharedLifecycle: false.instrumented/lifecycle.test.ts: the counter and thelag.lifecycle.transitionevent 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. Afterstop(), no events.setup-all-monitors.test.ts: the counter getsactivetohiddenandhiddentoactive, with the triggervisibilitychange. 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 afreezelistener ondocumentthat the page added earlier. Apagehidewithpersistedgives the statefrozenwith thetimeStampof the event.dispose()removes each listener.browser-apis.test.ts(only Chromium and Chrome): the browser has thefreezeandresumeevents, anddocument.wasDiscardedis false.cdp/freeze.test.ts(Chromium in the new headless mode): a real freeze of 1 s gives four transitions: tohidden, tofrozen, tohiddenand 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_transitionscounts the freeze and the resume.cdp/visibility.test.ts(Chromium in the new headless mode): a second page in front gives the transition tohidden, and then toactiveorpassive, with the triggervisibilitychange.
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
LifecycleStateMachine.ts: the state machine.instrumented/lifecycle.ts: the factory.measurement-conditions.ts: the use of the state in the measurement conditions.