Skip to main content

Public Pages Performance — Design

Date: 2026-05-12 Status: Approved Scope: Performance optimization for unauthenticated/public pages on odontox.io only

Problem

Pingdom HAR (2026-05-12) shows the public landing page at https://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: MISS on entry chunk + CSS despite immutable headers (cold edge node)
Root cause investigation (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"> for api.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:
  1. If no public route renders it → split into a chunk that loads only behind authenticated routes via dynamic import() at the route boundary.
  2. If a public route renders it but only below the fold → lazy-load that section with React.lazy() and Suspense.
  3. If a public route renders it above the fold → leave alone.
The existing 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

In ui/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
In ui/public/_headers:
  • Add Strict-Transport-Security: max-age=604800; includeSubDomains initially (1 week). After 1 week of clean traffic, escalate to max-age=63072000; includeSubDomains; preload and submit to hstspreload.org.
In R2 / asset pipeline:
  • 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=31536000 on 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
Metrics captured per run:
  • FCP, LCP, TTI, TBT, CLS, Speed Index
  • Total transferred bytes (JS, CSS, fonts, images, total)
  • Number of requests
  • Lighthouse Performance score
Storage: 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)
Non-regression:
  • 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.io dashboard after deploy
  • Zero React hydration warnings in browser console on any public route

Risks & mitigations

RiskLikelihoodImpactMitigation
Hydration mismatch from non-deterministic renderMediumVisible re-render flashPre-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 outMediumBrief lazy-load delay on interactionStep-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 yearsLowHard to revert if we ever need plain HTTPTwo-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 stateLowBuild fails or HTML is wrongPer-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 correctlyLow404 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:
  1. Phase 1 — Network hints + images (from Layer 3, lowest risk): preload/preconnect hints, HSTS short max-age, AVIF/WebP images. Ship. Observe 24h.
  2. Phase 2 — Bundle trim (Layer 2, medium risk, biggest size win): chunk-split dashboard deps. Ship. Observe 48h. Verify dashboard bundle sizes unchanged.
  3. Phase 3 — Prerender (Layer 1, medium risk, biggest first-paint win): prerender public routes. Ship. Observe 48h. Verify zero hydration warnings.
  4. Phase 4 — HSTS preload finalize: escalate HSTS max-age from 1 week to 2 years + add preload; submit to hstspreload.org.
Each deploy uses the standard 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)