Public Pages Performance — Design
Date: 2026-05-12 Status: Approved Scope: Performance optimization for unauthenticated/public pages onodontox.io only
Problem
Pingdom HAR (2026-05-12) shows the public landing page athttps://odontox.io/:
- Fully loaded: 1575ms
- DOMContentLoaded: 1315ms
- HTTP→HTTPS redirect: 146ms wasted round-trip
- Main JS chunk (
BBB4AvFt.js): 1.4MB raw / 432KB brotli — single chunk, 387ms total - CSS chunk (
B0vcyaqK.css): 504KB raw / 56KB brotli, 321ms total - Origin TTFB: ~384ms (
server-timing: cfOrigin;dur=384) - Cache status:
MISSon entry chunk + CSS despite immutable headers (cold edge node)
ui/vite.config.ts, ui/src/main.tsx, lazy-route audit):
The public entry chunk is bloated because dashboard-only deps are statically imported by code that the public landing route ultimately pulls in via the shared React provider tree. Specifically: the full Radix UI surface, recharts, framer-motion, gsap. The landing page itself does not render these, but they are included in the entry chunk because of import graph reachability.
Secondary issues:
- No
<link rel="preload">for above-the-fold fonts (Poppins woff2) - No
<link rel="preconnect">forapi.odontox.io - No HSTS preload — every cold visit pays the 146ms redirect
- No build-time prerendering — every visitor hydrates a full React tree from a blank shell
- Hero / og-image served as PNG/JPEG, not AVIF/WebP
Scope
In scope (public routes, root domain odontox.io and www.odontox.io)
/— Landing/legal— Legal hub/privacy,/terms,/book— Redirects into/legal(still need fast first-paint)/the-recall-effect— Marketing page/refer,/refer-payout-form— Referral pages/logout-success,/login-success— Post-auth transition pages
Out of scope (untouched)
- All authenticated routes (
/dashboard/*,/patients/*,/clinical/*, etc.) - Auth subdomain (
id.odontox.io/auth/*) - Dashboard subdomain (
go.odontox.io/*) - Patient portal (
portal.odontox.io/*) - Mobile app, server worker, database, any API endpoint
- Any change to authentication, routing, session, or data fetching logic
Strategy
Three independent, revertible layers. Each ships separately; each can be reverted without affecting the others.Layer 1 — Build-time prerendering of public routes
Add a Vite SSG plugin (vite-react-ssg preferred; falls back to vite-plugin-prerender if compatibility issues with React 19 + Tailwind v4 emerge during prototyping).
For each public route, the plugin renders the React tree at build time and writes dist/<route>/index.html containing the fully-rendered HTML. Cloudflare Pages serves these directly. React still hydrates on the client using the same JS bundle and the same component tree — only the first paint becomes real content instead of a blank shell.
Code paths unchanged. The same React components run at build time (Node) and runtime (browser). Hydration uses hydrateRoot instead of createRoot.
Hydration safety: components in the public route tree must produce deterministic output. Any window, localStorage, document, Date.now(), Math.random(), or user-agent-dependent read during render must be gated behind useEffect or useSyncExternalStore. Plan includes a pre-audit task.
Layer 2 — Trim the public JS bundle
Audit static import graph from the public entry. Identify dashboard-only deps (Radix UI components not used by public routes,recharts, framer-motion, gsap) that are reachable from the public entry via shared providers, layouts, or utility modules.
For each such dep:
- If no public route renders it → split into a chunk that loads only behind authenticated routes via dynamic
import()at the route boundary. - If a public route renders it but only below the fold → lazy-load that section with
React.lazy()and Suspense. - If a public route renders it above the fold → leave alone.
manualChunks config in ui/vite.config.ts already isolates the largest libs (vendor-dicom, vendor-three, vendor-pdf, etc.). This step extends that pattern.
Layer 3 — Network & asset hygiene
Inui/index.html:
<link rel="preload" as="font" type="font/woff2" href="/fonts/poppins-latin-400.woff2" crossorigin>for the two above-the-fold weights (400, 600)<link rel="preconnect" href="https://api.odontox.io" crossorigin>and<link rel="preconnect" href="https://assets.odontox.io" crossorigin><link rel="dns-prefetch">for analytics origins
ui/public/_headers:
- Add
Strict-Transport-Security: max-age=604800; includeSubDomainsinitially (1 week). After 1 week of clean traffic, escalate tomax-age=63072000; includeSubDomains; preloadand submit to hstspreload.org.
- Convert hero image + og-image to AVIF (primary) + WebP (fallback)
- Use
<picture>with<source type="image/avif">and<source type="image/webp"> - Confirm
Cache-Control: public, immutable, max-age=31536000on hashed asset paths
Measurement
Baseline captured before any change ships; post-deploy captured after each layer. Tools:- Local: Playwright + Lighthouse (mobile profile, throttled to “Slow 4G”)
- Real-edge: Google PageSpeed Insights API targeting
https://odontox.io/from a fixed location
- FCP, LCP, TTI, TBT, CLS, Speed Index
- Total transferred bytes (JS, CSS, fonts, images, total)
- Number of requests
- Lighthouse Performance score
docs/perf/baseline-2026-05-12.json and docs/perf/post-<layer>-<date>.json. A small markdown summary in docs/perf/README.md tracks the trend.
Acceptance criteria
Public routes:- Lighthouse mobile Performance ≥ 90 on
/(currently estimated 50–70) - FCP < 1.2s on Slow 4G profile
- LCP < 2.0s on Slow 4G profile
- TBT < 200ms
- Public entry JS (brotli) < 150KB (currently 432KB)
- All public routes visually identical (manual screenshot diff)
- All public route functionality preserved (manual smoke test: navigation, scroll, modals, forms)
- All authenticated/dashboard routes unaffected — verified by:
- Build artifact diff: chunks named under
vendor-*and the dashboard route chunks must be the same size ±2% before and after Layer 2 - Manual smoke test of
go.odontox.iodashboard after deploy
- Build artifact diff: chunks named under
- Zero React hydration warnings in browser console on any public route
Risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Hydration mismatch from non-deterministic render | Medium | Visible re-render flash | Pre-audit task to find and gate all window/localStorage/Date.now() reads behind useEffect. React 19 warns loudly — caught in dev. |
| Public component uses a dep we tried to split out | Medium | Brief lazy-load delay on interaction | Step-by-step audit per dep before moving anything. Static analysis via madge or hand-traced. If any public route uses it, dep stays. |
| HSTS preload locks us to HTTPS for 2 years | Low | Hard to revert if we ever need plain HTTP | Two-stage rollout: 1-week max-age first, escalate to preload only after clean traffic. Already 100% HTTPS today. |
| Prerender breaks on a route with weird state | Low | Build fails or HTML is wrong | Per-route prerender is opt-in via the route list. If a route errors, drop it from the list and ship the rest. Falls back to today’s SPA behavior. |
Cloudflare Pages doesn’t serve nested index.html correctly | Low | 404 on /legal/ | Pre-check Cloudflare Pages SSG support; fall back to flat /legal.html + _redirects rewrite if needed. |
Rollout
Four deploy phases, each independently revertible. Ordered by ascending risk so any regression is caught with the smallest possible change set still live:- Phase 1 — Network hints + images (from Layer 3, lowest risk): preload/preconnect hints, HSTS short max-age, AVIF/WebP images. Ship. Observe 24h.
- Phase 2 — Bundle trim (Layer 2, medium risk, biggest size win): chunk-split dashboard deps. Ship. Observe 48h. Verify dashboard bundle sizes unchanged.
- Phase 3 — Prerender (Layer 1, medium risk, biggest first-paint win): prerender public routes. Ship. Observe 48h. Verify zero hydration warnings.
- Phase 4 — HSTS preload finalize: escalate HSTS
max-agefrom 1 week to 2 years + addpreload; submit to hstspreload.org.
odontox-commit-deploy workflow (skill).
Out of scope, deferred
- Splitting public pages into a separate Astro/Vite app (option C from brainstorm) — bigger refactor, save for later if Layer 1+2 don’t hit acceptance criteria
- Service worker / offline support
- HTTP/3 tuning (already enabled by Cloudflare)
- Image CDN (already on R2 with Cloudflare CDN)
- Tailwind config tuning (v4 CSS-driven, already lean)

