Public Homepage Performance Optimization
On approval, copy this file to `docs/plans/public-homepage-performance.md` (per the prod-safe-incremental-plans convention) and execute phase-by-phase.
Context
Users report the public homepage (www.batbern.ch, / route) loads in 5β7s. A PageSpeed run (mobile / Slow 4G, PageSpeed Insights.pdf) confirms it: Performance 25/100, FCP 7.4s, LCP 15.8s, CLS 0.529, TBT 460ms. Accessibility/Best-Practices/SEO are all 96β100, so this is purely a loading-performance problem.
Four root causes, in impact order:
- Images are shipped raw and oversized. The LCP element is a 1.36 MB PNG hero (
HeroSection.tsx) served at full resolution; speaker photos are 337 KB at 541Γ542 but displayed 67Γ67; company logos are e.g. 1882Γ734 PNGs displayed at 123Γ48. Est. 1,943 KiB wasted. An image-resize Lambda@Edge already exists (infrastructure/lib/lambda/image-resize/index.ts) that converts to WebP and resizes via?w=&h=&fit=withCache-Control: immutableβ but the frontend only uses it for event-photo thumbnails. Everything else sends unsized URLs. - No
Cache-Controlreaches the browser. Every asset shows βCache TTL: Noneβ β 5,635 KiB re-downloaded on repeat visits. CloudFront does edge-cache (longCachePolicyTTLs), but the sharedSecurityHeadersResponseHeadersPolicy never emitsCache-Control, and S3 objects carry no metadata. Browser therefore never caches. - Render-blocking Google Fonts.
fonts.googleapis.comCSS is 120 KiB / 3,030 ms on the critical path (Inter + Noto Sans JP). Noto Sans JP is only needed for Japanese. - Heavy, under-split JS. Single
vendorchunk ~1.84 MB; auth components (LoginForm,RegistrationWizard,ResetPasswordForm,ForgotPasswordForm) are statically imported into the entry chunk; ~566 KiB unused JS. This delays React mount, which delays hero-image discovery (LCP βresource load delayβ = 2,740 ms).
Decisions (confirmed with user): full scope incl. infra; self-host Inter + load Noto Sans JP only for ja locale.
Intended outcome: mobile Performance from 25 β 80+, LCP < 2.5s, CLS < 0.1, repeat-visit transfer cut by ~5.6 MiB.
Constraints
- Staging IS production (account 188701360969). Each phase must be independently deployable to
developwithout endangering prod. - PRs to
developrun the full deploy-staging workflow with a blocking@smokePlaywright gate + auto-rollback (seeproject_playwright_staging_hardening). A flaky/failed smoke = real staging rollback. Keep@smokegreen every phase. - Reuse existing infrastructure (image Lambda, CDN cache policy that already keys on
w/h/fit). Do not add new image pipelines. - Frontend phases (1β5) are code-only β fast-path/hotswap deploy. Phase 6 is CDK β layer-based deploy (20β30 min), highest risk, goes last.
Phase 1 β Route images through the existing resize Lambda (frontend, highest ROI)
The single biggest win and zero infra risk β the Lambda + CDN cache policy already exist.
Status: β
DONE (commit per phase). Added cdnImage.ts (buildCdnImageUrl + buildCdnImageSrcSet, 12 unit tests). Applied to hero (responsive srcset 768/1280/1920), speaker photos (w=160 h=160), speaker + partner logos (h=128/h=256 contain, SVGs skipped), event-photo marquee (w=512 h=384). type-check + lint clean; 117 component tests green.
New util: web-frontend/src/utils/cdnImage.ts
buildCdnImageUrl(url, { w?, h?, fit? })β returnsurlunchanged if itβs not acdn.batbern.chraster image (skip SVGs: the Lambda rasterizes them and we want logos to stay vector) or if now/hgiven; otherwise appends?w=&h=&fit=. Cap at the LambdaβsMAX_DIM=2000.buildCdnImageSrcSet(url, widths[], { fit })β for the hero, builds asrcsetof WebP variants.
Apply (display size β requested size, ~2Γ for retina):
components/public/Hero/HeroSection.tsx:185β LCP hero. Usesrcset(e.g.w=768/1280/1920,fit=cover) +sizes="100vw", keepfetchPriority="high"+width/height+aspect-ratio. 1.36 MB PNG β ~120β180 KB WebP.components/public/Event/SpeakerDisplay.tsx:99(data-testid="speaker-photo") βw=160 h=160 fit=cover(2Γ of 80px). Keeploading="lazy".components/public/Event/SpeakerDisplay.tsx:139(logo) βh=128 fit=contain, skip if SVG.components/public/Partners/PartnerShowcaseCard.tsx:43βh=256 fit=contain, skip if SVG; also addwidth/height(currently none β CLS, see Phase 2).pages/public/HomePage.tsx:174event photos already use?w=256&h=192&fit=coverβ refactor to the new helper for consistency.
Verify: DevTools Network shows image/webp, sizes drop ~90%; existing ?w= event-photo behavior unchanged; @smoke + speaker/partner Playwright still green.
Phase 2 β CLS fixes (frontend, low risk)
Status: β
DONE. Added explicit width/height (SVG intrinsic ratio β 2.363:1) to the BATbern logo <img> in PublicNavigation (189Γ80), AppHeader (95Γ40), MobileDrawer (85Γ36), and to the TestimonialCard avatar (48Γ48 + loading="lazy"). Partner-card logos already have space reserved by the fixed-height card (max-h-32 inside h-48), so no fixed dims (variable aspect ratios). type-check clean; 82 nav/testimonial tests green.
Phase 2b β CLS root cause (added 2026-06-01). A LayoutShift PerformanceObserver against the production preview (mobile viewport) showed the entire CLS 0.529 was a single 0.475 shift of the <footer> at ~2.3s β NOT the images sized above. Cause: HomePage renders a short py-24 loading placeholder, so the footer sits ~300px down; when the event data resolves and the real min-h-screen hero + sections mount, the footer is shoved far down. Fix: give the loading and error states min-h-screen so the footer starts below the fold where it lands. Re-measured: CLS 0.475 β 0.000. This is the dominant CLS fix; the image-dimension work above is secondary hardening.
CLS 0.529 comes from images without intrinsic dimensions and late content pop-in.
- Add explicit
width/heightto logo<img>s lacking them:PublicNavigation.tsx:75(/BATbern_color_logo.svg),AppHeader.tsx,MobileDrawer.tsx,PartnerShowcaseCard.tsx,TestimonialCard.tsxavatar. - Reserve vertical space (min-height / aspect-ratio) for async-loaded sections on the homepage (speaker grid, partner marquee, event-photos marquee in
HomePage.tsx) so they donβt shift the footer when data resolves. - Font-swap CLS is addressed in Phase 3 (
size-adjust).
Verify: Lighthouse CLS < 0.1 locally; visually confirm no jump as data/fonts load.
Phase 3 β Remove dead render-blocking Google Fonts (frontend)
Status: β
DONE. Scope changed after investigation (confirmed with user): the loaded Google Fonts (Inter + Noto Sans JP) were never applied β the body, Tailwind sans, and MUI theme all resolve to 'Helvetica Neue'/system-ui, and nothing in src/ references Inter or Noto. So self-hosting Inter would add bytes for an unused font. Instead removed the render-blocking Google Fonts <link> + both preconnects from index.html, and dropped the now-dead google-fonts-stylesheets/google-fonts-webfonts Workbox runtimeCaching entries from vite.config.ts. Zero visual change; eliminates the ~120 KiB / ~3,030 ms render-blocking request, the 119 KiB unused CSS, and the font-swap CLS. Build verified: 0 googleapis/gstatic references in dist/.
CSP font-src/style-src in frontend-stack.ts still list the Google Fonts origins β harmless (nothing requests them); tightened in Phase 6.
Removes the 3,030 ms render-blocking 3rd-party request.
Verify: no fonts.googleapis.com request in Network; typography unchanged (system fonts); FCP drops; CLS drops (no swap reflow).
Phase 4 β JS code-splitting & bundle reduction (frontend)
Status: β
DONE (conservative). Lazy-loaded the 5 previously-eager auth forms (LoginForm, ForgotPasswordForm, ResetPasswordForm, RegistrationWizard, EmailVerification) via React.lazy β they were the only non-page eager imports in App.tsx and the public homepage never needs them. They now ship as small standalone chunks (β€3 KB gzip each); the entry index chunk dropped ~304 β ~297 KB gzip (raw 1247 β 1217 KB). All render inside the existing route <Suspense>. type-check clean; 90 App/auth tests green.
Phase 4b β Tone.js out of the eager vendor chunk (added 2026-06-01). The homepage console always logged βThe AudioContext was not allowed to startβ¦β (Γ2). Traced (LayoutShift-style observer + AudioContext constructor hook in the preview) to standardized-audio-contextβs capability probes running at module-init inside the eager vendor chunk. Root cause: the blanket manualChunks β 'vendor' rule pulled Tone.js + standardized-audio-context + automation-events into vendor, overriding the careful dynamic import('tone') in useBlobSounds (which is otherwise type-only + gesture-gated). Fix: in vite.config.ts manualChunks, return undefined for those three audio modules so they stay in the lazy import('tone') chunk (loaded only on the organizer blob page, after a click). Shared tslib/@babel/runtime deliberately left in vendor. Verified on the preview: audio chunk no longer loads on the homepage, 0 AudioContext warnings, console clean.
Intentionally NOT done: splitting the rest of the single ~1.84 MB vendor chunk. vite.config.ts:216-232 documents that one-vendor-chunk is deliberate β finer splitting reintroduces the @emotion/@mui CJSβESM factory-boundary init-order crash (TDZ / βCannot set properties of undefinedβ). Against the auto-rollback staging gate that gamble isnβt worth a few KB. The remaining homepage JS cost (vendor 573 KB gzip + vendor-mui 157 KB, both loaded because the root wraps everything in MUI ThemeProvider) would require separating the public app from the authed app β a larger refactor out of scope here. Skipped rollup-plugin-visualizer to avoid a lockfile change; sizes read from build output.
Smaller critical JS β React mounts sooner β earlier hero discovery (helps LCP delay) + lower TBT.
- Lazy-load auth components currently static in
App.tsx:18-22(LoginForm,ForgotPasswordForm,ResetPasswordForm,RegistrationWizard) viaReact.lazy+Suspenseβ theyβre not needed for the homepage. - Audit that heavy libs (
tinymce,tone,d3,recharts,framer-motion) are imported only by already-lazy non-public routes, never pulled into the entry/vendorchunk. Split into async chunks where they leak in. - Revisit
manualChunksinvite.config.ts:244-260β split safe vendors out of the single ~1.84 MBvendorchunk, respecting the documented CJS/ESM factory-boundary caveat (test each split; donβt regress the@mui/@emotiongrouping). - Add
rollup-plugin-visualizer(dev-only) to measure before/after; record sizes in the plan doc.
Verify: entry + homepage-critical JS shrinks (target the 566 KiB unused-JS finding); npm run build clean; full app routes still load (lazy auth/admin verified via Playwright).
Phase 5 β LCP discoverability & preconnect (frontend)
Status: β
DONE. Added <link rel="preconnect" href="https://cdn.batbern.ch"> + a dns-prefetch fallback to index.html, so the TLS handshake to the media CDN is warm before JS resolves the hero URL (report lists this origin as a preconnect candidate, ~340 ms LCP saving). No crossorigin β hero/logo <img> requests are not CORS. Build verified: both tags present in dist/index.html.
Skipped (optional): injecting a <link rel="preload" as="image"> for the dynamic hero once event.themeImageUrl resolves. With fetchPriority="high" already set, the hero now a small WebP (Phase 1), and the connection pre-warmed, the marginal gain didnβt justify the added moving part (helmet head injection on a per-event URL).
Verify: LCP βresource load delayβ drops in a fresh Lighthouse run.
Phase 6 β Infra: viewer Cache-Control + verify compression (CDK, prod deploy, LAST)
Status: β
CODE DONE β DEPLOY PENDING. frontend-stack.ts: extracted the shared securityHeadersBehavior + baseCustomHeaders into consts, kept the existing SecurityHeaders policy for HTML/SEO (no Cache-Control β index.html stays uncached), and added a second StaticAssetsHeaders policy (security headers + Cache-Control: public, max-age=31536000, immutable) attached to /assets/*, /*.js, /*.css, /static/* only. storage-stack.ts: added a ContentCacheHeaders ResponseHeadersPolicy with the same immutable Cache-Control on the CDN default behavior (covers pass-through originals/SVGs the resize Lambda doesnβt touch). CDK tsc compiles; 20 stack tests pass incl. 5 new assertions (Cache-Control present on static-assets + CDN policies, absent on the HTML policy, 2 distinct frontend policies).
Deferred to keep this risky deploy minimal: (1) CSP font-src/style-src tightening β leaving the now-unused Google Fonts origins is strictly safe (nothing requests them); removing risks a CSP regression that could trigger auto-rollback, not worth it in the same deploy. (2) BucketDeployment cacheControl β redundant since the ResponseHeadersPolicy overrides at CloudFront and the bucket is OAC-locked (never served directly).
β οΈ Deploy is a real prod CloudFront update (layer-based, ~20β30 min) and not yet run. Before deploy: review cd infrastructure && npm run diff:staging. After deploy verify with the curl checks below.
Recovers the 5,635 KiB repeat-visit waste. Riskiest (touches live CloudFront) β ships last, alone.
infrastructure/lib/stacks/frontend-stack.ts:
- A CloudFront behavior allows only one ResponseHeadersPolicy, and the current one is shared with the no-cache HTML behavior β so create a second policy
staticAssetsHeaders: samesecurityHeadersBehavior+customHeadersBehaviorasSecurityHeadersplus{ header: 'Cache-Control', value: 'public, max-age=31536000, immutable', override: true }. - Attach
staticAssetsHeadersto the hashed-asset behaviors only:/assets/*(266),/*.js(275),/*.css(284),/static/*(257). Leave the default (HTML) behavior on the existing no-cache policy +htmlNoCacheFunction. - Optionally set
cacheControlon theBucketDeployment(line ~382) as S3-metadata belt-and-suspenders.
infrastructure/lib/stacks/storage-stack.ts (cdn.batbern.ch):
- Add a ResponseHeadersPolicy to the default behavior with
Cache-Control: public, max-age=31536000, immutable(media keys are content-addressed UUIDs β effectively immutable). The Lambda already sets this on resized responses; this covers pass-through originals/SVGs that currently get nothing.
Compression: PageSpeed flagged βNo compression appliedβ on the document despite compress:true. After deploy, curl -sI the HTML and an asset and confirm content-encoding: br|gzip; if HTML is uncompressed, investigate the htmlNoCacheFunction / content-type (likely a non-issue once asset headers land, but verify).
- Tighten CSP
font-src/style-srcto drop Google Fonts origins (safe now that Phase 3 self-hosts). - Add/extend CDK unit tests (
infrastructure/test/unit/frontend-stack.test.ts,storage-stack.test.ts) asserting theCache-Controlcustom header on static behaviors and the no-cache HTML behavior.
Verify: npm run diff:staging reviewed before deploy; post-deploy curl -sI https://www.batbern.ch/assets/index-*.js shows cache-control: public, max-age=31536000, immutable; HTML still no-cache; curl -sI https://cdn.batbern.ch/<media> shows immutable; repeat-visit PageSpeed transfer drops ~5.6 MiB.
Overall Verification
- Per phase:
cd web-frontend && npm run build && npm run lint && npm run type-check; run@smokebefore merge (scripts/ci/run-playwright-tests.sh staging --scope smoke) β staging gate auto-rollback makes this mandatory. - End-to-end: re-run PageSpeed on
https://batbern.ch/(mobile). Targets: Performance 80+, LCP < 2.5s, CLS < 0.1, TBT < 200ms. Compare first-visit vs repeat-visit transfer (Phase 6 win). - Bundle: record
rollup-plugin-visualizerbefore/after sizes in the plan doc. - Doc drift: per
CLAUDE.md, any infra/behavior change consults.github/doc-drift-mappings.yml; pure perf refactors get[no-doc].
Critical Files
| File | Phase |
|---|---|
web-frontend/src/utils/cdnImage.ts (new) |
1 |
web-frontend/src/components/public/Hero/HeroSection.tsx |
1 |
web-frontend/src/components/public/Event/SpeakerDisplay.tsx |
1 |
web-frontend/src/components/public/Partners/PartnerShowcaseCard.tsx |
1,2 |
web-frontend/src/pages/public/HomePage.tsx |
1,2,5 |
web-frontend/src/components/public/Navigation/PublicNavigation.tsx + AppHeader/MobileDrawer |
2 |
web-frontend/index.html |
3,5 |
web-frontend/src/index.css, tailwind.config.js, MUI theme, src/i18n/config.ts |
3 |
web-frontend/vite.config.ts (manualChunks, PWA caching, visualizer) |
3,4 |
web-frontend/src/App.tsx (lazy auth routes) |
4 |
infrastructure/lib/stacks/frontend-stack.ts |
6 |
infrastructure/lib/stacks/storage-stack.ts |
6 |
infrastructure/test/unit/{frontend,storage}-stack.test.ts |
6 |
infrastructure/lib/lambda/image-resize/index.ts (reference only β no change) |
1 |