Skip to content

perf: cut homepage LCP from 9.9 s → < 3 s (Lighthouse 50 → 90) #3987

@SebastienMelki

Description

@SebastienMelki

Summary

Desktop Lighthouse (13.0.2, devtools throttling) on https://www.worldmonitor.app/ returned Performance 50 (Acc 92 / BP 96 / SEO 100). The other categories are healthy — performance is the bottleneck.

2026-05-31_15-07-01Z.webm
Metric Current Target Verdict
FCP 6.3 s < 1.8 s poor
LCP 9.9 s < 2.5 s poor
Speed Index 12.0 s < 3.4 s poor
TBT 70 ms < 200 ms good
CLS 0.127 < 0.1 needs improvement

LCP breakdown:

  • TTFB: 130 ms ← server is fast
  • Element render delay: 18,670 ms ← browser is choking

A Playwright recording from a fast home connection (unthrottled) confirms the felt experience: the page's load event fires at 11.6 s and the first 4 panel titles don't appear until 21.2 s. Total transfer is ~12 MB across 125 requests.

Baseline video + perf JSON: lighthouse-videos/before/2026-05-31_15-07-01Z.{webm,json} (gitignored — see PR for the recording script in scripts/record-load.spec.ts).

Root causes (ranked by impact)

  1. 2.98 MB Clerk bundle is eagerly imported and 96% unused on first paint. Single biggest win available. Lazy-loading it alone saves ~2.88 MB and an estimated 2–3 s of LCP.
  2. ~80 simultaneous API requests fan out from panels-Cf8BmyAe.js on initial render because every panel self-loads on mount in src/app/panel-layout.ts. This saturates the browser's per-host connection pool — every request finishes between 25,000 and 32,615 ms even though TTFB is 130 ms. The max critical path latency is 32.6 s. Server isn't slow; the browser queue is.
  3. 522 KiB of render-blocking CSS — including a 70 KiB MapLibre stylesheet that is 98% unused on first paint because the map component isn't mounted yet.
  4. div.header is the entire 0.127 CLS. #authWidgetMount and #unifiedSettingsMount mount async (Clerk + settings UI); the header reflows when they hydrate.
  5. bfcache is fully disabled by Cache-Control: no-store on the HTML response (vercel.json lines 111, 118, 137 — root, /index.html, and the SPA catch-all). The no-store is unnecessary; no-cache would revalidate without killing bfcache.
  6. No preconnect to api.worldmonitor.app — the origin every initial-load request hits. Lighthouse estimates 680 ms of LCP savings from adding it. Similar story for the Sentry ingest origin (310 ms).
  7. Sentry init is on the critical path and eats 1.96 s of CPU before LCP.
  8. DOM has 33,226 elements, including a single <tbody> with 618 children. Style recalc costs 1.2 s of main-thread time and will hurt INP under real interaction.
  9. Two non-composited animations (findings-pulse on box-shadow, .live badge on border-color/color) run on the layout/paint thread instead of the compositor.

Plan

11 parallelizable sub-issues, partitioned by file ownership so they don't merge-conflict.

Conflict graph (parallel work without merge collisions)

Issue main.ts panel-layout.ts index.html vite.config.ts CSS
#3988 (A) clerk init only auth mount section (~560–650)
#3989 (B) header only
#3990 (C) renderLayout body (~920–982)
#3991 (D) lines 1–3
#3992 (E) full
#3993 (F)
#3994 (G) sentry import line
#3995 (H) manualChunks main.css + per-panel
#3996 (I) keyframes file
#3997 (J)
#3998 (K) low-contrast rules

Sequencing notes:

  • A and G both edit imports in main.ts — land A first, then G rebases.
  • A and C both edit panel-layout.ts — A only touches the header section, C only touches renderLayout() and createPanel block. Safe in parallel.
  • H and B both touch CSS — B owns a new src/styles/header.css; H owns main.css. Safe in parallel.

Suggested implementation order (solo)

  1. perf(A): lazy-load Clerk (-2.88 MB / -2-3s LCP) #3988 (A — Lazy Clerk) — biggest single LCP delta per LOC
  2. perf(C): stop the 80-request panel fan-out (-10-15 s critical path) #3990 (C — Panel fan-out) — biggest critical-path delta after A
  3. perf(D): defer MapLibre CSS into the MapContainer chunk (-70 KiB blocking, -510 ms FCP) #3991 (D — MapLibre CSS) + perf(E): HTML head hygiene — preconnect api.worldmonitor.app (-990 ms LCP) #3992 (E — preconnects) + perf(F): drop no-store from HTML in vercel.json to restore bfcache #3993 (F — vercel.json) — all single-file, can land in one PR if you want
  4. perf(B): reserve header dimensions (CLS 0.127 → < 0.05) #3989 (B — header CLS) + perf(G): defer Sentry init off the critical path (-1.96 s main-thread CPU) #3994 (G — Sentry defer) + perf(I): rewrite findings-pulse and .live animations as composite-only #3996 (I — composite animations) — small CSS / single-import edits
  5. perf(H): split main.css (452 KiB) into per-panel CSS (-300+ KiB blocking, -1+ s FCP) #3995 (H — CSS split) + perf(J): virtualize the 618-child tbody panel #3997 (J — table virtualization) — bigger refactors, last
  6. perf(K): accessibility & CSP cleanup (92 → 98+) #3998 (K — A11y) — easy cleanup, can ride any PR

Each PR: npm run typecheck, npm run lint, relevant Playwright e2e (e2e/auth-ui.spec.ts for #3988, e2e/map-harness.spec.ts for #3991).

Expected outcome

Metric Now After A+C+D+E+F After all 11
LCP 9.9 s ~4 s ~2.5 s
FCP 6.3 s ~3 s ~1.5 s
Speed Index 12.0 s ~5 s ~3 s
CLS 0.127 0.127 < 0.05
Total bytes 13.6 MB ~9 MB ~6 MB
Performance score 50 ~75 ~90

Verification

After each PR:

  1. Re-run Lighthouse desktop on the same URL.
  2. Re-run RECORD_PHASE=after npx playwright test --config=scripts/lighthouse-video.config.ts to capture a comparison video in lighthouse-videos/after/.
  3. Confirm bfcache via DevTools → Application → Back/Forward Cache.

Metadata

Metadata

Labels

P1High priority, fix soonclaudeGenerated with Claude CodeperformancePerformance optimizationrefactorCode restructuring, architecture

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions