Sessions and Delivery
Episode 5: one pageview, one session, exactly two records, three queues, three triggers, and beacons that outlive the page
Copy this guide and your current settings as a prompt for your AI agent.
Preview agent promptSee the guide, code and settings included when you copy.
Episode 4 ended with a visitor inside an experiment. Now the question that decides whether your numbers can be trusted: what exactly gets written down? The answer is an invariant: one pageview, one session, exactly two records, an exposure and a page-view. Everything else in measurement hangs off that pair.
On this page
01Session identity
A session is minted per pageview, with one subtlety: the first variant application reuses the session id created at boot, so anything logged before init, like a hydration warning, shares the id of the session it belongs to. Every navigation after that mints a fresh UUID. Session equals pageview, by construction.
02The pair
On every apply, startSession writes two records:
| Record | Carries | Queue | Table |
|---|---|---|---|
| Session | The full assignment output from episode 1, the SDK version, the browser context | exposure | impressions (the denominator) |
page_view event |
Type, session id, URL, token | behavior | events |
One pageview, one impression, one page-view. Never more.
03Engagement
Then the SDK waits to see if the visit is real. session_start fires on the
first click anywhere, or after ten seconds counted visibility-aware
(ENGAGED_SESSION_THRESHOLD): a background tab’s clock re-checks when the tab
is shown. Whichever comes first, and exactly once: the engaged flag is set
before the event is queued, so nothing can double-fire it.
04Viewed variants
Every applied node carries coframe-exp-id. On scroll, and when rendering
completes, the SDK checks visibility. The first time an element is on screen
it is stamped coframe-experienced and one modification_view event is
queued: once per variant, per page.
05The three queues
| Queue | Holds | Endpoint | Table |
|---|---|---|---|
| behavior | Clicks, page-views, session-starts | batched_events/<projectId>/ |
events |
| health | The monitoring stream | batched_monitoring/<projectId>/ |
monitoring_sdk |
| exposure | Sessions | session_result/ |
impressions |
06Flush triggers
- Size: any queue reaching 32 (
batchSize). - Time: an immediate flush at apply, then every three seconds
(
FLUSH_EVENTS_TIME). - Lifecycle:
beforeunload,pagehide,visibilitychange, three overlapping signals because mobile browsers fire them inconsistently.
Every trigger funnels through flushQueuedEvents, behind the consent gate
from episode 2.
07sendBeacon, no fallback
Delivery is navigator.sendBeacon and nothing else. A beacon is handed to the
browser itself and survives navigation: a conversion click that immediately
leaves the page still arrives. There is deliberately no fetch fallback. The
source comment explains why: a fetch dies with the page, so clicks would
vanish while impressions survive, skewing every rate downward. If the beacon
fails, usually an ad-blocker, the SDK only warns.
08The join
impressions is the denominator: who was exposed, to which variant.
events is the numerator: what they did. The stats engine joins them per
session and divides. Because every pageview writes exactly one exposure, the
denominator cannot inflate, and a rate you cannot inflate is a rate you can
trust.
09Where in the code
| File | What to look for |
|---|---|
sdk/sdk-js/src/init/modifyPage.ts |
createApplyVariants (session id reuse at boot, fresh id per navigation), FLUSH_EVENTS_TIME, setupFlushInterval |
sdk/sdk-js/src/init/setupTracking.ts |
startSession, ENGAGED_SESSION_THRESHOLD, updateViewedVariants, lifecycle flushes |
sdk/sdk-js/src/api.ts |
The three queues, batchSize, flushQueuedEvents, the endpoints, sendBeacon and the no-fallback comment |
# Sessions and Delivery Episode 5: one pageview, one session, exactly two records, three queues, three triggers, and beacons that outlive the page [Episode 4](../docs/frames-audiences-lanes.html) ended with a visitor inside an experiment. Now the question that decides whether your numbers can be trusted: what exactly gets written down? The answer is an invariant: one pageview, one session, exactly two records, an exposure and a page-view. Everything else in measurement hangs off that pair. [Video: Video: Runtime Walkthrough, episode 5: session identity, the pair, engagement, viewed variants, the three queues, flush triggers, sendBeacon, and the join (3m 25s, narrated)](assets/videos/sdk-internals-05-sessions-delivery.mp4) Watch: 3m 25s — Runtime Walkthrough, episode 5: session identity, the pair, engagement, viewed variants, the three queues, flush triggers, sendBeacon, and the join ## Session identity A session is minted per pageview, with one subtlety: the first variant application reuses the session id created at boot, so anything logged before init, like a hydration warning, shares the id of the session it belongs to. Every navigation after that mints a fresh UUID. Session equals pageview, by construction. ## The pair On every apply, `startSession` writes two records: Record | Carries | Queue | Table | Session | The full assignment output from episode 1, the SDK version, the browser context | exposure | `impressions` (the denominator) | `page_view` event | Type, session id, URL, token | behavior | `events` | One pageview, one impression, one page-view. Never more. ## Engagement Then the SDK waits to see if the visit is real. `session_start` fires on the first click anywhere, or after ten seconds counted visibility-aware (`ENGAGED_SESSION_THRESHOLD`): a background tab’s clock re-checks when the tab is shown. Whichever comes first, and exactly once: the engaged flag is set before the event is queued, so nothing can double-fire it. ## Viewed variants Every applied node carries `coframe-exp-id`. On scroll, and when rendering completes, the SDK checks visibility. The first time an element is on screen it is stamped `coframe-experienced` and one `modification_view` event is queued: once per variant, per page. ## The three queues Queue | Holds | Endpoint | Table | behavior | Clicks, page-views, session-starts | `batched_events/<projectId>/` | `events` | health | The monitoring stream | `batched_monitoring/<projectId>/` | `monitoring_sdk` | exposure | Sessions | `session_result/` | `impressions` | ## Flush triggers - Size: any queue reaching 32 (`batchSize`). - Time: an immediate flush at apply, then every three seconds (`FLUSH_EVENTS_TIME`). - Lifecycle: `beforeunload`, `pagehide`, `visibilitychange`, three overlapping signals because mobile browsers fire them inconsistently. Every trigger funnels through `flushQueuedEvents`, behind the consent gate from [episode 2](../docs/identity-and-consent.html). ## sendBeacon, no fallback Delivery is `navigator.sendBeacon` and nothing else. A beacon is handed to the browser itself and survives navigation: a conversion click that immediately leaves the page still arrives. There is deliberately no `fetch` fallback. The source comment explains why: a fetch dies with the page, so clicks would vanish while impressions survive, skewing every rate downward. If the beacon fails, usually an ad-blocker, the SDK only warns. ## The join `impressions` is the denominator: who was exposed, to which variant. `events` is the numerator: what they did. The stats engine joins them per session and divides. Because every pageview writes exactly one exposure, the denominator cannot inflate, and a rate you cannot inflate is a rate you can trust. ## Where in the code File | What to look for | `sdk/sdk-js/src/init/modifyPage.ts` | `createApplyVariants` (session id reuse at boot, fresh id per navigation), `FLUSH_EVENTS_TIME`, `setupFlushInterval` | `sdk/sdk-js/src/init/setupTracking.ts` | `startSession`, `ENGAGED_SESSION_THRESHOLD`, `updateViewedVariants`, lifecycle flushes | `sdk/sdk-js/src/api.ts` | The three queues, `batchSize`, `flushQueuedEvents`, the endpoints, `sendBeacon` and the no-fallback comment |