Record visitors who leave before PostHog initializes - #1133
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
blove
force-pushed
the
blove/pre-analytics-beacon
branch
from
September 23, 2026 00:53
aa218b3 to
2ef992f
Compare
Contributor
posthog-js arrives in a ~60KB chunk that shares the connection with everything else on first load. Measured on production with Lighthouse mobile and devtools throttling, it finished downloading at ~4.2s and sent its first event, the $pageview, at ~4.5s. A visitor on a slow connection who leaves before that is never recorded, so PostHog's short-visit figures are computed only from visitors whose connections were fast enough. This measures the missing cohort. An inline beacon, first in <body>, arms while the HTML parses and costs no request. On pagehide, or the first time the page goes hidden (phones often never fire pagehide), it sends one marketing:pre_analytics_exit event: elapsed_ms, trigger, pathname-only source_page, referrer_host, viewport_width, effective_type. Random distinct_id, no person profile, no IP. instrumentation-client.ts disarms it right after posthog.init(), and also when its gate declines, so the two never both report a visit. It is deliberately not a $pageview, so bounce rate and sessions are unchanged. It mirrors posthog-js's own unload beacon: same /ingest proxy, application/json, ip=0. The function ships as its own source text, so it must be self-contained. The spec evaluates the serialized string on its own; referencing a module constant inside the function fails it. A parity table pins its copy of the local-host rule to shouldCaptureAnalytics. The event is routed through analyticsEvents so the taxonomy gate sees it: that gate finds events by track() and analyticsEvents.X, and would have missed a name that only appears in a JSON body. Documented in docs/gtm/taxonomy.md.
blove
force-pushed
the
blove/pre-analytics-beacon
branch
from
September 23, 2026 01:43
2ef992f to
9b04592
Compare
blove
enabled auto-merge (squash)
September 23, 2026 01:43
Contributor
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PostHog can't see visitors who leave before it loads. This PR adds a small inline beacon that records them, so we know how big that missing cohort is before deciding anything about deferring PostHog.
Why
Measured on production (Lighthouse mobile, devtools throttling over HTTP/2):
$pageviewA visitor on a slow connection who leaves before ~4.5s is never recorded. So PostHog's figures for short mobile visits — bounce rate, session length, the "2–3s median" — are computed only from visitors whose connections were fast enough for PostHog to load. The slowest leavers were never in the data.
PostHog's own LCP cost is small by comparison: blocking it entirely on prod moves LCP from 3,973 to 3,797ms (~176ms, 4%). Deferring it would cut into this same cohort further, so this measures first.
What it does
apps/website/src/lib/analytics/pre-analytics-beacon.ts, inlined first in<body>byPreAnalyticsBeacon:pagehide, or the first time the page goeshidden(phones often never firepagehide), it sends onemarketing:pre_analytics_exitevent:elapsed_ms, since navigation starttrigger—pagehideis a definite leave;hiddenusually is, but the visitor may returnsource_page(pathname only, never the query string),referrer_host,viewport_width,effective_type(Chromium only)distinct_id,$process_person_profile: false,ip=0.instrumentation-client.tsdisarms it right afterposthog.init()— from then on posthog-js owns the session and flushes its own events on unload — and also when its gate declines. The two never both report a visit.It's deliberately not a
$pageview: bounce rate and sessions are unchanged, so this can't corrupt existing metrics. Read it alongside them to size the missing cohort, not merged into them.It mirrors posthog-js's own unload beacon exactly — same same-origin
/ingestproxy,application/jsonBlob,ip=0. The payload uses PostHog's documented capture format. The events it sends are within the privacy policy's existing "how pages are used" and "broad technical details" disclosure.Guards
toString()), so it may reference nothing outside its body. A test evaluates the serialized string on its own. Mutation-tested: making the function use a module constant fails it withReferenceError, while every direct-call test still passes.shouldCaptureAnalytics.track()andanalyticsEvents.X, and would have missed this event, since the name only appears inside a JSON body. The event is now routed throughanalyticsEvents— confirmed by the gate failing on it before thedocs/gtm/taxonomy.mdrow was added, and passing after.POSTHOG_INIT_OPTIONSis unchanged, so itstoEqualpin holds.Verification
nx test website,nx lint website,nx build website;tools/posthogsuite (69/69)/ingestrequest intercepted locally:ping,application/json,trigger=pagehide,elapsed_ms=2673,viewport_width=375, the?utm_sourcestripped;marketing:pre_analytics_exitevents are arriving. CI builds carry no PostHog token, so the beacon renders nothing there; the unit tests are what guard it in CI.Reading it
Once it's collecting:
Noticed, not changed
PostHog fetches
surveys.js(33.5KB) on every page load. If surveys aren't in use, disabling them in the project would save mobile data.🤖 Generated with Claude Code