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.
| Field | Type | Always emitted? |
|---|---|---|
scroll_depth_max_px | number | yes (when scrollTracking is on) |
est_scroll_depth_max_pct | number | only when the page is real-scrollable |
est_scroll_depth_pct | number | only when the page is real-scrollable |
The est_ convention
Section titled “The est_ convention”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”).
Why a percentage can decrease
Section titled “Why a percentage can decrease”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.
Reset boundaries
Section titled “Reset boundaries”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.
Reliability across rendering modes
Section titled “Reliability across rendering modes”Rough estimates, not measured:
| Mode | _px | _pct |
|---|---|---|
| Static / classic MPA | ~95% | ~90% |
| SSR + client routing (Next App Router, Remix) | ~95% first page, ~80% later | requires analytics.page() on nav |
| Client-only SPA | ~85% | ~70% (denominator unstable longer) |
| Infinite-scroll feeds | reliable | near-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).
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();}Disabling
Section titled “Disabling”Set timeOnPage: { scrollTracking: false } to suppress all three fields on
every beacon.