Skip to content

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.

Engagement time only advances when all three of GA4’s conditions hold:

  1. The page is visible (document.visibilityState === "visible").
  2. The window is focused (the tab has focus, not just visible behind another).
  3. The page is within a pageshowpagehide lifecycle 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.

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.

Each guardrail maps to a config option; see Configuration for the current defaults.

GuardrailOptionWhy
Minimum thresholdminSecondsDrops “bounces” — very short sessions are noise.
Maximum capmaxSecondsCaps “zombie tab” sessions so a forgotten tab can’t skew totals.
Idle gateidleSecondsExcludes AFK-reading windows.

All four options set in context (values shown are the defaults):

analytics.ts
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)
},
});

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-minSeconds delta is skipped but does not drain the accumulator — otherwise a window shorter than minSeconds would 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.

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: sendBeaconfetch 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.

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.