Skip to content

perf: PostHog and Google Analytics load once the page has drawn - #328

Merged
ashtonanderson merged 9 commits into
mainfrom
claude/perf-round-6
Nov 3, 2026
Merged

ashtonanderson merged 9 commits into
mainfrom
claude/perf-round-6

Conversation

@ashtonanderson

@ashtonanderson ashtonanderson commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Sixth round: PostHog and Google Analytics load once the page has drawn, instead of before it. Stacked on #327 (claude/perf-round-5); this PR's base is that branch. Details are in LOADING.md ("Round 6").

Why

On a phone, analysis and /play spend most of their wait running JavaScript before they can draw.

  • PostHog (~170 KB uncompressed) was in Next's start-up chunk. instrumentation-client.ts initialised it before the app ran. _app initialised it a second time, which PostHog ignored.
  • Google Analytics (next/script's default) downloaded and ran right after hydration. On the analysis page that was while the game was being built.

Change (387921b)

  • PostHog loads with a dynamic import and starts once the page has loaded and the browser is idle (or 5 s after the load event). It uses the same settings instrumentation-client.ts used, so its behaviour is unchanged.
  • Early events tracked before then wait in src/lib/posthogClient. They're sent once PostHog has loaded, with the time they happened.
  • Google Analytics uses lazyOnload.
  • Removed: the PostHog init in instrumentation-client.ts (the file now only starts the LCP observer, below), _app's ignored second init, and PostHogProvider (nothing used its hooks).
  • Moved: whenPageIsIdle goes to src/lib, shared by _app and the Stockfish provider (unchanged).

Results (round 5 → this branch)

Local builds with a test PostHog key (/ingest, swallowed by the local proxy), 5 runs each, builds alternating. The phone is Chrome's 4x CPU throttling with 4G. Another project's Stockfish batch kept ada at load 95–100 of 160, so times vary.

round 5 → round 6
JavaScript shared by all pages (gzip) 237 → 181 kB
Analysis game fully visible, phone, first visit 3.58 → 3.13 s
/play setup dialog on screen, phone, first visit 2.51 → 2.29 s
Home page JavaScript, phone, first visit 653 → 554 KB
/play: choosing a side 1.5 s after the dialog, until the game phone 1.57 → 1.56 s, desktop 0.88 → 0.91 s
/play: choosing the instant the dialog appears, until the game phone 1.64 → 2.06 s, desktop 1.03 → 1.17 s

The cost is the last row. The analytics now start as the page first goes idle, which on /play is just after the dialog appears. A visitor who chooses at once waits for them.

I tried waiting longer (idle, 1 s, idle again) and dropped it. That moved the analytics' start-up onto clicks a second or so after the dialog appears, which is worse.

Analytics

Checked with PostHog's debug log, using an ordinary Chrome user agent; PostHog drops headless browsers as bots.

  • PostHog start: about 1.2–1.7 s on a desktop instead of 0.3 s. Its page view is stamped then.
  • Google Analytics: its page view goes out about 0.3 s later than before.
  • Early events: an event tracked before PostHog loaded (download_model_modal_shown, at 0.66 s) was sent at 1.3 s with its own time.
  • Quick exits: visitors who leave within the first seconds may not be counted.
  • Early errors: errors thrown before PostHog loads aren't captured.

After the rebase on main

#330 and #332 changed PostHog's settings. PostHog now starts with main's settings from src/lib/posthog-browser-config.ts:

  • events go through /ingest
  • a page view for each page within the app
  • exceptions
  • web vitals

The resolution: instrumentation-client.ts doesn't initialise PostHog, and loadPostHog passes main's settings. The one change to them is that LCP is sent by the observer below.

Checked against main with a local stand-in for PostHog. The visit: an analysis game, a click, an uncaught error, a link to /play, a game against Maia, then leaving the page.

  • Same events:
    • page views for the analysis page, /play and /play/maia
    • analysis_game_loaded and play_game_started
    • $exception and $web_vitals
    • early events keep their own time
  • Later start: on the emulated phone PostHog starts at ~2.2 s on /play and 2.5–4.9 s on the analysis page, against ~1.5 s on main.
    • A visitor who has moved to another page by then gets a page view only for the page PostHog starts on.
    • Choosing a side at once on /play, the /play page view still arrived in 3 of 3 runs.
  • Lost LCP, now fixed (next section).
  • Not this branch: CLS and INP, reported as the page is hidden, didn't arrive on either build.

LCP for visitors who tap before PostHog loads

PostHog's web-vitals script reports LCP at the first tap or key press after it has loaded, or when the page is hidden. It then holds it 5 s before sending.

  • The loss: a visitor who has already tapped by the time the script loads loses LCP when the tab closes.
  • Where: choosing a side at once on /play on the phone sent FCP but no LCP in 3 of 3 runs. That was on round 8's tip, where the dialog takes taps at ~1.3 s, before PostHog starts. On this branch alone the dialog takes taps at ~1.4 s, as PostHog starts, and LCP arrived in 3 of 3.

The fix (02afe94): src/lib/largestContentfulPaint, started by instrumentation-client.ts before the app, with none of PostHog's code.

  • Same rules as web-vitals: it takes the last paint before the first click or key press, or before the page is hidden.
  • Same event: it sends PostHog's $web_vitals event with $web_vitals_LCP_value and $web_vitals_LCP_event, through the queue in src/lib/posthogClient.
  • No double counting: PostHog itself now sends FCP, CLS and INP only (web_vitals_allowed_metrics).
  • Left out: web-vitals' attribution, and a new LCP after a back/forward-cache restore.

Checked on the emulated phone, 3 runs each, with a local stand-in for PostHog:

without the fix with it main
Round 8's tip: choosing at once, runs that sent LCP 0 of 3 3 of 3 3 of 3
Round 8's tip: 3 s pause, LCP events per run 1 1 1
This branch alone: choosing at once / 3 s pause 3 of 3 / 1 per run 3 of 3 / 1 per run
  • Values: each matched the browser's own LCP to the millisecond (0.49–0.56 s on /play; main 0.49–0.55 s).
  • Sent sooner: at once, not 5 s later, so fewer are lost to tabs closing.
  • Same events otherwise: the analysis-to-/play visit sent the same events as main. The analysis page's LCP on a desktop was 1.28–1.30 s, against main's 1.25–1.29 s.
  • URL: the event carries the URL of the page that loaded. Main's, sent 5 s after the tap, carried whatever page the visitor was on by then: /play/maia, for /play's LCP and the analysis page's alike.
  • Size: JavaScript shared by all pages 181 → 182 kB (0.6 kB gzip).

Stockfish

Checked with scripts/perf/chart-probe.mjs on the analysis page: round 5 against rounds 6 and 7 together, 8 steps per run. Each pair was run in both orders.

round 5 → rounds 6–7
Desktop: depth 12 after a step 0.01 ± 0.01 s later (sooner on 16 of 32 steps)
Desktop: depth reached before the next step 16.97 → 16.97
Phone: depth 12 after a step 0.19 ± 0.21 s later (sooner on 28 of 63 steps)
Phone: steps reaching depth 12 64 → 63 of 64
Phone: depth reached before the next step 14.73 → 14.61

On the phone the run order mattered more than the code:

  • With round 5 run first, the branch was 0.07 ± 0.31 s sooner.
  • With the branch run first, it was 0.44 ± 0.29 s later.

Either way the branch was sooner on about half the steps. Nothing here runs while the page steps through a game.

Testing

🤖 Generated with Claude Code

@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
maia-platform-frontend Ready Ready Preview Nov 3, 2026 6:32am UTC

Request Review

ashtonanderson and others added 7 commits November 2, 2026 18:48
…drawn

posthog-js (~170 KB of JavaScript) was in Next's start-up chunk and
initialised before the app ran (instrumentation-client.ts), and Google
Analytics (next/script's default afterInteractive) downloaded and ran right
after hydration: on the emulated phone both ran while the analysis page was
building its game.

PostHog now loads (a dynamic import) and initialises once the page has
loaded and the browser is idle, or 5 s after the load event, with the
settings instrumentation-client.ts used (_app's own init, with other
settings, was ignored as a second init and is gone, as is PostHogProvider,
whose hooks nothing used). Events tracked before then wait in
src/lib/posthogClient and are sent once it has loaded, with the time they
happened; its page view is captured then. Google Analytics uses
next/script's lazyOnload. Visitors who leave within the first seconds may
not be counted. whenPageIsIdle moves to src/lib for _app and the Stockfish
provider.

Waiting longer (idle, 1 s, idle again) was tried and dropped: it moved the
analytics' start-up onto the clicks of visitors who choose on /play a
second or so after the dialog appears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased on #330 and #332: the same events arrive as on main, PostHog
starts later on the phone, and LCP is lost for visitors who tap before
PostHog's web-vitals script has loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostHog's web-vitals code takes the LCP as final at the first click or key
press after it has loaded, or when the page is hidden, and then holds it for
5 s, which a closing tab doesn't wait for. With PostHog loading once the page
has drawn (387921b), a visitor who chose a side on /play before it had loaded
sent no LCP.

src/lib/largestContentfulPaint starts from instrumentation-client.ts, before
the app, with no PostHog code. It sends the last largest-contentful-paint
entry before the first click or key press (once that click has been handled)
or before the page is hidden, as the $web_vitals event PostHog sends, with
its $web_vitals_LCP_value and $web_vitals_LCP_event properties, through
posthogClient's queue. PostHog itself now sends FCP, CLS and INP only. The
event carries the URL of the page that loaded (main's carried the URL of
whatever page the visitor was on 5 s after the tap). It leaves out
web-vitals' attribution, and sends no new LCP after a back/forward-cache
restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lost LCP was measured with round 7 on top, whose dialog takes taps
before PostHog starts; on round 6 alone LCP arrived. With
src/lib/largestContentfulPaint, choosing a side at once on round 8's tip
sends LCP in 3 of 3 runs (0 of 3 before), a 3 s pause sends exactly one,
and the values match the browser's own LCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ashtonanderson and others added 2 commits November 3, 2026 06:28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rebase on #330 and #332 gave round 6's commits new IDs, and the
old ones are no longer on any branch. The round 5 build it was measured
against keeps its old ID, marked as from before the rebase. The table of
commits gains the LCP fix, 02afe94.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ashtonanderson
ashtonanderson changed the base branch from claude/perf-round-5 to main November 3, 2026 06:34
@ashtonanderson
ashtonanderson marked this pull request as ready for review November 3, 2026 06:34
@ashtonanderson
ashtonanderson merged commit 82c920b into main Nov 3, 2026
8 checks passed

This branch was successfully deployed

1 active deployment
Preview — 85e68c4a Deployed Nov 3, 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