Skip to content

Scroll Depth

When scrollTracking is enabled (the default), every beacon — heartbeat, internal-nav, and terminal — carries up to three scroll fields. They ride along on the existing engagement beacon; there is no extra dispatch.

FieldTypeAlways emitted?
scroll_depth_max_pxnumberyes (when scrollTracking is on)
est_scroll_depth_max_pctnumberonly when the page is real-scrollable
est_scroll_depth_pctnumberonly when the page is real-scrollable

est_ means “the denominator can shift.” The percentage fields are computed as scrollY / (scrollHeight - viewportHeight). Both terms can change mid-session — lazy images, font swap, ad injection, infinite scroll — so the percentage is directional, not authoritative. The prefix encodes that lossiness in the field name, so an analyst can’t accidentally build a KPI on the noisy signal without seeing the warning.

scroll_depth_max_px carries no prefix because scrollY is a deterministic browser-API integer. Trust it. Use it for absolute thresholds (“user scrolled past 2400px”).

est_scroll_depth_max_pct can go down between heartbeats — and that’s intentional. scroll_depth_max_px is monotonic (a peak), but the denominator (scrollHeight - viewportHeight) grows when lazy content loads below the fold. Same pixels over a bigger document = a lower percentage. The _px field stays honest; the _pct field reflects the page as it actually rendered.

Scroll state resets when page_view_id rolls — SPA navigation (analytics.page() or popstate) and bfcache restore. Tab-switch-back does not reset — refocusing a tab is the same page-view from the server’s perspective, and resetting would discard the user’s pre-switch scroll peak.

Rough estimates, not measured:

Mode_px_pct
Static / classic MPA~95%~90%
SSR + client routing (Next App Router, Remix)~95% first page, ~80% laterrequires analytics.page() on nav
Client-only SPA~85%~70% (denominator unstable longer)
Infinite-scroll feedsreliablenear-meaningless — the prefix earns its keep

Scroll tracking needs no extra wiring — it rides the engagement beacon. In an SPA, fire analytics.page() on each route change so the scroll peak resets at the right boundary (see Reset boundaries).

analytics.ts
import { AnalyticsBrowser } from "@adpharm/silo-analytics";
// Scroll tracking is on by default; shown explicit here. Set `false` to
// suppress all three scroll fields on every beacon.
export const analytics = AnalyticsBrowser.load({
writeKey: "your-write-key",
env: window.env.PUBLIC_APP_ENV,
timeOnPage: { scrollTracking: true },
});
// In an SPA, fire `page()` on each client route change. This rolls the
// page_view_id and resets the scroll peak — without it, scroll state would
// accumulate across routes as one long page-view.
export function onRouteChange() {
analytics.page();
}

Set timeOnPage: { scrollTracking: false } to suppress all three fields on every beacon.