Skip to content

Defer client telemetry off the load critical path#28

Merged
pedropaulovc merged 1 commit into
mainfrom
perf/defer-telemetry-off-critical-path
May 29, 2026
Merged

Defer client telemetry off the load critical path#28
pedropaulovc merged 1 commit into
mainfrom
perf/defer-telemetry-off-critical-path

Conversation

@pedropaulovc

Copy link
Copy Markdown
Contributor

Problem

PageSpeed flagged a 2,172 ms maximum critical path latency. The tail was the App Insights /v2.1/track POST 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.

decipher.ms                    — 159 ms
/_astro/BriefingForm…js        — 209 ms
/_astro/Layout…js              — 223 ms
…/v2.1/track (App Insights)    — 2,172 ms   ← dominates the chain

Fix

The body telemetry script now lazy-imports the telemetry chunk and fires the first pageview only on requestIdleCallback (4 s timeout), with a load-event fallback. web-vitals reads buffered LCP/FCP/CLS entries, so deferring observer registration loses no metrics, and the SDK already flushes on visibilitychange/pagehide.

Build impact

Chunk Before After
Layout…js (eager) 14.7 KB 1.7 KB (idle bootstrap only)
telemetry.js (web-vitals + AI client) eager, in chain dynamic import — not fetched until idle

/track and the 14.7 KB telemetry bundle now load after the main thread is idle, out of the load window. Eager critical chain collapses to doc → BriefingForm…js, dropping max critical path latency from ~2,172 ms to ~200 ms.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 29, 2026 15:43
@pedropaulovc pedropaulovc enabled auto-merge May 29, 2026 15:44

@pedropaulovc pedropaulovc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lgtm

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 a load fallback when unsupported).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/layouts/Layout.astro
Comment on lines +119 to +123
const startTelemetry = () =>
import("@/lib/telemetry").then(({ initTelemetry, trackPageview }) => {
initTelemetry();
trackPageview(location.pathname, document.title);
});
Comment thread src/layouts/Layout.astro
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 pedropaulovc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lgtm

@pedropaulovc pedropaulovc merged commit 52d9501 into main May 29, 2026
4 checks passed
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)
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.

2 participants