Time on Page
The core of the library is an engagement timer that reproduces GA4’s verified mechanics and then closes its documented gaps. This page explains how it decides when time counts, when it pauses, and when it sends.
The three-condition rule
Section titled “The three-condition rule”Engagement time only advances when all three of GA4’s conditions hold:
- The page is visible (
document.visibilityState === "visible"). - The window is focused (the tab has focus, not just visible behind another).
- The page is within a
pageshow→pagehidelifecycle window.
The moment any condition drops — tab switch, window blur, minimize, navigation away — the timer pauses and preserves the accumulated milliseconds. When the condition returns, it resumes from where it left off.
Idle detection
Section titled “Idle detection”Beyond visibility and focus, the timer also pauses after idleSeconds of
zero user input — GA4’s “AFK reading” weakness, where a
visible, focused, but untouched tab keeps accruing time. Any of mousemove,
mousedown, keydown, scroll, touchstart, click, or wheel restarts the
counter.
The activity set is deliberately curated. pointermove / touchmove are
excluded — they fire continuously during passive cursor drift or a scroll
gesture and would defeat the point of idle detection. Set idleSeconds: 0 to
disable the gate entirely.
Data-quality guardrails
Section titled “Data-quality guardrails”Each guardrail maps to a config option; see Configuration for the current defaults.
| Guardrail | Option | Why |
|---|---|---|
| Minimum threshold | minSeconds | Drops “bounces” — very short sessions are noise. |
| Maximum cap | maxSeconds | Caps “zombie tab” sessions so a forgotten tab can’t skew totals. |
| Idle gate | idleSeconds | Excludes AFK-reading windows. |
All four options set in context (values shown are the defaults):
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// The data-quality guardrails from the table above, set in context. Each one// shapes when engagement time counts or dispatches — the timer itself is// automatic once `load()` runs.export const analytics = AnalyticsBrowser.load({ writeKey: "your-write-key", env: window.env.PUBLIC_APP_ENV, timeOnPage: { minSeconds: 5, // drop sub-5s "bounces" as noise (default) maxSeconds: 900, // cap "zombie tab" sessions at 15m (default) idleSeconds: 30, // pause after 30s of zero input — AFK-reading gate (0 disables) heartbeatSchedule: [30, 60, 120, 240, 480, 600], // bounded-exponential; last step repeats (false disables) },});Heartbeat
Section titled “Heartbeat”GA4 sends engagement once, at the end. That loses everything if the browser
crashes or the OS kills a backgrounded mobile tab. Instead, this library fires a
bounded-exponential heartbeat on the heartbeatSchedule (the last step
repeats forever), each carrying the engagement delta since the last beacon.
Worst-case data loss on an abrupt termination is therefore one schedule-step,
not the whole session. See Configuration for the default
schedule.
Two subtleties worth knowing:
- Clock-based, not engagement-based. The schedule step advances on every fire regardless of whether a beacon actually went out. Backoff is about server load, not engagement precision.
- Peek-then-drain. A heartbeat that fires with a sub-
minSecondsdelta is skipped but does not drain the accumulator — otherwise a window shorter thanminSecondswould silently zero the timer every heartbeat and the user’s time would never land.
The schedule resets to step 0 on a navigation boundary and on visibility-return,
both of which start a fresh engagement window. Pass heartbeatSchedule: false
to disable heartbeats.
Reliable delivery
Section titled “Reliable delivery”End-of-session beacons are sent on pagehide and visibilitychange, with a
Safari-only beforeunload fallback (Safari’s visibilitychange is unreliable
at teardown). Transport degrades gracefully: sendBeacon → fetch with
keepalive → a localStorage queue (50-entry cap) that replays on the next page
load. Privacy signals (DNT/GPC) are re-checked at both send time and flush time.
SPA navigation
Section titled “SPA navigation”Internal route changes are detected from Segment page events (fire
analytics.page() on each client route change) and browser back/forward via
popstate. Each navigation rolls the page_view_id and resets the engagement
window, so a multi-route SPA session is recorded as distinct page-views rather
than one long one. See Server-Side Dedup for how the
identifiers reconstruct a session.