Skip to content

Record visitors who leave before PostHog initializes - #1133

Merged
blove merged 1 commit into
mainfrom
blove/pre-analytics-beacon
Sep 23, 2026
Merged

blove merged 1 commit into
mainfrom
blove/pre-analytics-beacon

Conversation

@blove

@blove blove commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

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):

Time PostHog
679 → 4,194 ms library downloads (61KB)
4,513 ms first event sent — the $pageview

A 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> by PreAnalyticsBeacon:

  • Arms while the HTML parses: an inline script with no request of its own (1,452 bytes).
  • 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, since navigation start
    • triggerpagehide is a definite leave; hidden usually is, but the visitor may return
    • source_page (pathname only, never the query string), referrer_host, viewport_width, effective_type (Chromium only)
  • Random distinct_id, $process_person_profile: false, ip=0.
  • instrumentation-client.ts disarms it right after posthog.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 /ingest proxy, application/json Blob, 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

  • Self-containment. The function ships as its own source text (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 with ReferenceError, while every direct-call test still passes.
  • Gate parity. The beacon runs before any bundle, so it carries its own copy of the local-host rule. An 18-row table pins it to shouldCaptureAnalytics.
  • Taxonomy. The code-taxonomy gate finds events via track() and analyticsEvents.X, and would have missed this event, since the name only appears inside a JSON body. The event is now routed through analyticsEvents — confirmed by the gate failing on it before the docs/gtm/taxonomy.md row was added, and passing after.
  • POSTHOG_INIT_OPTIONS is unchanged, so its toEqual pin holds.

Verification

  • nx test website, nx lint website, nx build website; tools/posthog suite (69/69)
  • Real Chromium against a production build with a dummy token, every /ingest request intercepted locally:
    • visitor leaves before PostHog loads → one beacon, sent as a ping, application/json, trigger=pagehide, elapsed_ms=2673, viewport_width=375, the ?utm_source stripped;
    • PostHog initializes normally → zero beacons; PostHog's own four requests.
  • After deploy — needs someone with PostHog access: confirm marketing:pre_analytics_exit events 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:

-- How many visits PostHog never saw, by device width and stay length
SELECT
  multiIf(properties.viewport_width < 768, 'phone', properties.viewport_width < 1024, 'tablet', 'desktop') AS device,
  properties.trigger AS trigger,
  count() AS exits,
  quantile(0.5)(properties.elapsed_ms) AS p50_ms,
  quantile(0.9)(properties.elapsed_ms) AS p90_ms
FROM events
WHERE event = 'marketing:pre_analytics_exit' AND timestamp > now() - INTERVAL 30 DAY
GROUP BY device, trigger
ORDER BY exits DESC

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

@vercel

vercel Bot commented Sep 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 23, 2026 1:50am UTC

Request Review

@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

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
blove force-pushed the blove/pre-analytics-beacon branch from 2ef992f to 9b04592 Compare September 23, 2026 01:43
@blove
blove enabled auto-merge (squash) September 23, 2026 01:43
@github-actions

github-actions Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 6aef88b into main Sep 23, 2026
32 checks passed
@blove
blove deleted the blove/pre-analytics-beacon branch September 23, 2026 20:52

This branch was successfully deployed

1 active deployment
Preview – threadplane 9b045928 Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant