Defer client telemetry off the load critical path#28
Merged
Conversation
The App Insights /v2.1/track POST was firing during page load, and its slow westus2 ingestion round-trip dominated the critical request chain (~2,172 ms max critical path latency). Telemetry is pure analytics, so it has no business on the load path. Lazy-import the telemetry chunk (web-vitals + AI client) and fire the first pageview only on requestIdleCallback, with a load-event fallback. This splits the eager Layout script from 14.7 KB to 1.7 KB and turns the telemetry bundle into a dynamic-import chunk that isn't fetched until the main thread is idle. web-vitals reads buffered LCP/FCP/CLS entries, so deferring observer registration loses no metrics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
decipher-ms | 1415565 | Commit Preview URL Branch Preview URL |
May 29 2026, 03:44 PM |
There was a problem hiding this comment.
Pull request overview
This PR moves client-side telemetry (web-vitals + App Insights track POST) off the initial page-load critical path by deferring telemetry initialization and the first pageview until the browser is idle, reducing critical request chain latency during load.
Changes:
- Replaced eager telemetry import with a dynamic
import()to split telemetry into a lazy-loaded chunk. - Scheduled telemetry init + initial pageview via
requestIdleCallback(with aloadfallback when unsupported).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+119
to
+123
| const startTelemetry = () => | ||
| import("@/lib/telemetry").then(({ initTelemetry, trackPageview }) => { | ||
| initTelemetry(); | ||
| trackPageview(location.pathname, document.title); | ||
| }); |
Comment on lines
+116
to
+118
| // pageview only once the main thread is idle, so the slow App Insights | ||
| // /track round-trip happens well after load. web-vitals reads buffered | ||
| // LCP/FCP/CLS entries, so deferring registration doesn't lose metrics. |
pedropaulovc
added a commit
that referenced
this pull request
May 29, 2026
…#29) ## Problem #28 lazy-loaded the telemetry chunk, but a fresh mobile PageSpeed run on the deployed code showed `/v2.1/track` **still** topped the critical request chain: ``` Maximum critical path latency: 2,353 ms https://decipher.ms - 309 ms BriefingForm…js - 393 ms, 13.81 KiB Layout…Ct5Qvt6k.js - 399 ms, 1.43 KiB ← #28 shrank this ✓ telemetry…js - 504 ms, 6.31 KiB /v2.1/track - 2,353 ms ← still the tail ``` Lighthouse classifies a request as *critical* primarily by **priority**, and a plain `fetch()` defaults to **High**. Deferring *when* it fired (via `requestIdleCallback`) only pushed the node later in the timeline — it didn't remove it. ## Fix Switch the ingestion transport to **`navigator.sendBeacon`** — a background-priority transport Lighthouse does not count as critical — with a low-priority keepalive `fetch` fallback for browsers without it. Beacon also reliably survives page unload, which is exactly what the existing `visibilitychange`/`pagehide` flush relies on. Expected result: `/track` leaves the chain entirely, dropping max critical path latency from ~2.35 s to ~0.5 s (next node is the deferred telemetry chunk). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pedropaulovc
added a commit
that referenced
this pull request
May 29, 2026
## Why Follow-up to #28/#29. A mobile PageSpeed run reported **LCP 5.1 s**, yet local Lighthouse with the *identical* throttling (Moto G Power, Slow 4G, 4× CPU) scored **100 / LCP 1.5 s**, and real TTFB is 40–170 ms. The gap is environmental: the content pages were **SSR on a Worker**, so a slow/cold root-document response from a distant test location cascades (HTML late → fonts late → Playfair lands ~5 s → heading LCP 5.1 s). And for a content site, the client JS was carrying weight it didn't need. ## Changes **1. Prerender content pages** (`index, about, work, briefs/*, legal, privacy, 404`) → static HTML served from the CDN edge, no per-request Worker render, no cold-start TTFB variance. API + OIDC routes stay SSR. To decouple from SSR, **client telemetry self-configures**: the App Insights iKey + ingestion endpoint (already public — they ship in every page today) move to a shared `telemetry-config.ts`; `environment` is resolved from the hostname on each side. The connection-string secret parse and the `__telemetryConfig` injection are removed. `build.format: "file"` so pages emit as `about.html` (served at `/about`) rather than `about/index.html` (which 307-redirects `/about → /about/`) — **keeps URLs identical** to the existing canonical/sitemap/nav scheme, no redirect hop. **2. Drop client-side zod.** The server validation (`server/briefing.ts`) is hand-rolled, not zod — so the ~13 KiB client zod bundle was pure duplication. Replaced with a small `validate()` mirroring the same rules. With zod gone, the form script falls under Astro's inline threshold and is **inlined into the page (one fewer request)**. zod removed from deps. ## Net effect on client JS (a content site now ships analytics-only JS) | | Before | After | |---|---|---| | Form (`BriefingForm`) | 13.79 KiB gz request | **inlined, 0 requests** | | Telemetry | 6.37 KiB, deferred (idle, beacon) | unchanged | | Content page TTFB | per-request Worker (variable) | **static edge (consistent)** | ## Verification (local `wrangler preview`) - All content routes → **200, no redirect**; `/about`, `/work`, etc. served directly - SSR routes intact: `/sitemap.xml`, `/llms.txt`, `/.well-known/openid-configuration` → 200 - Telemetry **beacon fires on the static page** (self-config works without SSR injection) - Form validation: empty → 5 correct per-field errors (role optional); valid → clears - `npm run build` + `eslint` clean > Note: `/.well-known/jwks.json` 500s on local preview (missing `OIDC_PRIVATE_KEY` secret locally); live prod is 200 and this PR touches no OIDC code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
Problem
PageSpeed flagged a 2,172 ms maximum critical path latency. The tail was the App Insights
/v2.1/trackPOST firing during page load — its westus2 ingestion round-trip is slow, and it has no business on the load path since it's pure analytics.Fix
The body telemetry script now lazy-imports the telemetry chunk and fires the first pageview only on
requestIdleCallback(4 s timeout), with aload-event fallback. web-vitals reads buffered LCP/FCP/CLS entries, so deferring observer registration loses no metrics, and the SDK already flushes onvisibilitychange/pagehide.Build impact
Layout…js(eager)telemetry.js(web-vitals + AI client)/trackand the 14.7 KB telemetry bundle now load after the main thread is idle, out of the load window. Eager critical chain collapses todoc → BriefingForm…js, dropping max critical path latency from ~2,172 ms to ~200 ms.🤖 Generated with Claude Code