Mobile PageSpeed Optimization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Take the marketing landing (apex odontox.io) from a 49/100 mobile PageSpeed score to 90+ by eliminating dead 11 MB assets, deferring third-party scripts, inlining critical CSS, preloading the right resources, and lazy-loading below-the-fold media.
Architecture: The marketing landing is part of a single Vite + React 19 SPA at ui/ that also serves the dashboard and auth subdomains. We will optimise the shared index.html, defer module-level scripts (Sentry, GTM) so they don’t block boot, replace oversized media with appropriately-sized AVIF/WebP, and lazy-mount below-fold video components — all without changing build config (module preload stays disabled — chunk recovery depends on it).
Tech Stack: Vite 6, React 19, Tailwind v4, Cloudflare Pages, Cloudflare Workers. Image conversion via sharp (Node, installed on-demand). No new runtime dependencies.
Reference report: .superpowers/brainstorm/odontox-mobile-pagespeed-report.md
Deployment: After every task, use the odontox-commit-deploy skill (one commit per task, deploy to Cloudflare Pages, force-promote canonical). Each task is independently shippable.
File Map
Edited:ui/index.html— resource hints, inline critical CSS, defer GTMui/src/main.tsx— defer Sentry initui/public/_headers— add AVIF Cache-Controlui/public/gtam-loader.js(new) — GTM loader (deferred)ui/src/components/landing/PremiumHero.tsx— replace 1.8 MB ruby.webp icon with 32×32 AVIFui/src/components/landing/RubySection.tsx— same icon swapui/src/components/landing/DicomSection.tsx— same icon swapui/src/components/landing/BridgeSection.tsx— defer Bridge.webm until intersectionui/src/App.tsx— defer GlobalLoadingProvider video sources until first loading state
ui/public/ruby-icon.avif(new, ~3 KB) — 32×32 icon-sized AVIFui/public/ruby-icon.webp(new, ~2 KB) — 32×32 icon-sized WebP
ui/public/ruby.png(11 MB, never referenced in code)
Task 1: Resource hints — preconnect, preload Poppins 500/700, preload critical above-fold assets
Files:-
Modify:
ui/index.html:9-16 -
Step 1: Read current
<head>resource-hint block
head -20 /Users/ssh/Documents/Beta-App/odontoX/ui/index.html
Confirm lines 9–15 contain the existing preconnect/preload tags.
- Step 2: Replace the resource-hint block
ui/index.html lines 9–15. Replace:
preconnect (the connection is mandatory anyway since the script is in <head>). Google + DoubleClick get dns-prefetch (lighter, fires only if needed). All four Poppins weights get preload so text in any weight renders without re-flow.
- Step 3: Build to verify no syntax errors
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -20
Expected: Build succeeds. Confirm dist/index.html contains the new preload tags: grep -c 'poppins-' dist/index.html → should print 4.
- Step 4: Commit via odontox-commit-deploy
odontox-commit-deploy skill. Commit message:
Task 2: Inline critical.css into <head> so first paint never waits for JS
Files:
- Modify:
ui/index.html(add<style>block) - Modify:
ui/src/main.tsx:4(remove theimport './critical.css'line) - Reference:
ui/src/critical.css(read full contents, inline into HTML)
main.tsx so it ships inside the JS bundle. Inlining it eliminates the JS dependency for first paint and lets the browser paint the body background + skeleton hero before the 370 KB bundle finishes parsing.
- Step 1: Read
critical.csscontents
cat /Users/ssh/Documents/Beta-App/odontoX/ui/src/critical.css
Capture the full contents (currently 103 lines; lines 1–102 are the @font-face declarations, :root vars, body styles, and hero skeleton).
- Step 2: Inline the CSS into
index.html
ui/index.html. Just before </head> (currently line 71), insert:
[PASTE …] with the actual contents of critical.css (all 103 lines, verbatim).
- Step 3: Remove the JS import
ui/src/main.tsx. Remove line 4: import './critical.css'.
- Step 4: Build and verify
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -20
Expected: Build succeeds. Confirm critical.css is no longer imported in main bundle: grep -l "poppins-400.woff2" dist/index.html should match (the inlined block contains it).
- Step 5: Smoke-test in dev mode
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run dev in the background. Open http://localhost:5173 in a browser. Visually confirm:
- Body background renders immediately (not white-flash)
- Poppins font applies on first paint (no FOUC swap from system to Poppins)
- No console errors about missing CSS
- Step 6: Commit via odontox-commit-deploy
Task 3: Defer Google Tag Manager until after page is interactive
Files:- Modify:
ui/index.html:64-66(remove early GTM tags) - Create:
ui/public/gtm-loader.js - Modify:
ui/index.html(add deferred loader script tag near</body>) - Modify:
ui/public/gtag-init.js(move into loader)
<head> as async. async does not block parsing but it does compete for bandwidth and CPU during the critical render window. We delay loading until 2 seconds after window.load (or first user interaction, whichever is sooner) — long enough that LCP and TTI have both fired.
Conversion-tracking note: this delays Ads conversion firing by ~2s on every visit. Anyone landing and immediately bouncing in under 2s will not be attributed. For a B2B SaaS landing this is acceptable; if the user disputes, they can lower the delay to 500ms.
- Step 1: Create
ui/public/gtm-loader.js
/Users/ssh/Documents/Beta-App/odontoX/ui/public/gtm-loader.js:
- Step 2: Remove the eager GTM tags from
<head>
ui/index.html. Remove lines 64–66:
- Step 3: Add the deferred loader at the end of
<body>
ui/index.html. Just after the main script tag (<script data-cfasync="false" type="module" src="/src/main.tsx"></script> on line 74), insert:
- Step 4: Delete the now-redundant
gtag-init.js
rm /Users/ssh/Documents/Beta-App/odontoX/ui/public/gtag-init.js
(All initialisation logic is now inside gtm-loader.js.)
- Step 5: Build and verify
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5
Expected: Build succeeds. Run: grep -c 'gtm-loader' dist/index.html → 1. Run: grep -c 'gtag/js' dist/index.html → 0 (no eager GTM in HTML anymore).
- Step 6: Smoke-test in dev mode
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run dev in the background. Open browser DevTools → Network tab → filter “gtag”. Reload page. Confirm:
gtag/js?id=AW-...request appears ~2s afterloadevent, NOT during initial render- Scroll the page within first 2s → GTM loads immediately on first scroll
window.dataLayerexists in console after GTM loads
- Step 7: Commit via odontox-commit-deploy
Task 4: Defer Sentry initialisation until after first paint
Files:- Modify:
ui/src/main.tsx:1-45
main.tsx before React renders. The @sentry/react package + browser tracing + replay integration adds significant parse+execute cost on the critical path. Sentry just needs to be initialised before any user-triggered code path, not before first paint.
Risk: any error thrown during boot (between <script> parse and Sentry.init) won’t be captured. We mitigate with a tiny window.addEventListener('error', …) pre-handler that queues errors for Sentry once it loads.
- Step 1: Read current
main.tsxSentry block
sed -n '1,50p' /Users/ssh/Documents/Beta-App/odontoX/ui/src/main.tsx
Confirm lines 1–45 match what’s documented in the file map.
- Step 2: Replace the Sentry init block with a deferred version
ui/src/main.tsx. Replace lines 1–57 (from import * as Sentry through the end of the sentry-test block).
Replace this:
main.tsx (chunk-reload logic, image-loading policy, redirects, etc.) untouched.
- Step 3: Type-check
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | tail -20
Expected: no type errors. If requestIdleCallback typing fails, narrow the cast:
- Step 4: Build
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5
Expected: Build succeeds. Sentry will be code-split into its own chunk (since it’s now a dynamic import).
- Step 5: Smoke-test in dev
- No
@sentrychunk loads during initial render - A Sentry chunk loads ~2s after
loadevent ?sentry-test=1URL still fires test events after the deferred load
- Step 6: Commit via odontox-commit-deploy
Task 5: Delete dead ruby.png (11 MB orphan) and resize ruby.webp icon
Files:
- Delete:
ui/public/ruby.png(11 MB, zero references in code) - Create:
ui/public/ruby-icon.avif(~3 KB, 64×64 px) - Create:
ui/public/ruby-icon.webp(~2 KB, 64×64 px) - Modify: all 8 files that reference
ruby.webpas a small icon
ruby.webp is 1.8 MB at full resolution but is displayed as a 16×16 to 40×40 icon on the landing page (h-3.5 w-3.5, h-4 w-4, h-5 w-5, etc.). We create a 64×64 (2x retina) icon-sized version and swap all small-icon usages to it. The 1.8 MB original stays for the few places that actually need full resolution (PdfReports, etc.) — we’ll audit those.
- Step 1: Confirm
ruby.pngis unreferenced
cd /Users/ssh/Documents/Beta-App/odontoX && grep -rn "ruby\.png" ui/src ui/index.html ui/public 2>/dev/null
Expected: zero matches. If anything matches, stop and report — do not delete.
- Step 2: Delete
ruby.png
rm /Users/ssh/Documents/Beta-App/odontoX/ui/public/ruby.png
- Step 3: Generate icon-sized AVIF + WebP from the existing webp
sharp ephemerally and run a one-off conversion:
- Step 4: Identify icon usages
cd /Users/ssh/Documents/Beta-App/odontoX/ui && grep -rn 'ruby\.webp' src --include="*.tsx" --include="*.ts" 2>/dev/null
The icon usages (small display size) are in:
src/components/landing/PremiumHero.tsxlines 180, 240, 274, 304src/components/landing/RubySection.tsxline 28src/components/landing/DicomSection.tsxline 127src/components/icons/OdontoXAIIcon.tsxline 6src/components/files/DicomViewer.tsxline 810src/components/files/XRayWorkstation.tsxline 716src/components/files/XRayRubyPanel.tsxline 322src/pages/UpgradeRequest.tsxline 368src/lib/dicom-utils.tsline 266 (used in 28×28 PDF embed — keep this one on the high-res webp, PDFs need quality)
ruby.webp (NOT swap to icon) in:
-
src/components/landing/RubySection.tsxline 171 (large hero-area image — verify usage context first) -
src/lib/dicom-utils.tsline 266 (PDF embed) -
Step 5: Swap icon usages to
ruby-icon.webpwith AVIF fallback via<picture>
dicom-utils.ts:266 (a string template), leave it as ruby.webp (PDF rendering doesn’t support <picture>).
For RubySection.tsx:171, first read context (10 lines around line 171). If the image is rendered larger than ~96×96 px, keep on /ruby.webp (full-resolution). If it’s small, swap to icon.
- Step 6: Verify
ruby.webp(full-res) is still used somewhere
cd /Users/ssh/Documents/Beta-App/odontoX/ui && grep -rn 'ruby\.webp' src 2>/dev/null | wc -l
Expected: at least 1 match (PDF embed). If zero, delete the full-res ruby.webp too.
- Step 7: Build and visual smoke-test
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5
Expected: succeeds. Run dev server. Visually confirm Ruby icons render at the same size as before on the landing page (Hero, RubySection, DicomSection). Stop dev server.
- Step 8: Commit via odontox-commit-deploy
Task 6: Lazy-mount BridgeSection video (3.2 MB) until it scrolls into view
Files:
- Modify:
ui/src/components/landing/BridgeSection.tsx:29(and the surrounding video tag)
- Step 1: Read the current Bridge video usage
grep -n -B2 -A20 'Bridge\.webm\|BRIDGE_VIDEO_URL' /Users/ssh/Documents/Beta-App/odontoX/ui/src/components/landing/BridgeSection.tsx
Identify the <video> tag that uses BRIDGE_VIDEO_URL.
- Step 2: Gate the video behind an IntersectionObserver
BridgeSection.tsx, add the React import if not present:
<video src={BRIDGE_VIDEO_URL} ...> in:
<div> so layout doesn’t shift — verify CLS stays 0.)
- Step 3: Type-check and build
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npx tsc --noEmit 2>&1 | grep BridgeSection
Expected: no errors.
Run: npm run build 2>&1 | tail -5
Expected: succeeds.
- Step 4: Smoke-test in dev
Bridge.webmdoes not load on initial page load- Scroll down toward the BridgeSection —
Bridge.webmloads when section is within 300 px of viewport - Video plays normally once loaded
- No visible layout shift when video mounts (CLS check)
- Step 5: Commit via odontox-commit-deploy
Task 7: Defer GlobalLoadingProvider video sources until first loading state
Files:
- Modify:
ui/src/App.tsx:985 - Read first:
ui/src/components/ui/GlobalLoadingProvider.tsx(or wherever the provider lives — find with grep)
GlobalLoadingProvider currently receives defaultVideoSources={['/loader.webm', '/loader.mp4']} as an eager prop. The provider likely either preloads or attaches <video> tags with these sources at mount, pulling in 1.3 MB + 2.3 MB = 3.6 MB even when no loading is happening. On the landing page, the global loader never shows, so this is pure waste.
- Step 1: Find the provider source
grep -rn 'GlobalLoadingProvider' /Users/ssh/Documents/Beta-App/odontoX/ui/src --include="*.tsx" --include="*.ts" -l 2>/dev/null
Read the provider component. Identify how defaultVideoSources is used.
- Step 2: Determine the right fix
-
Case A: Provider attaches
<video>tags at mount and they auto-load. Addpreload="none"to the<video>tags so the browser does not fetch the bytes until.play()is called. -
Case B: Provider explicitly fetches/preloads the video bytes at mount. Defer the fetch to the first call to
showLoading(). -
Case C: Provider only uses the sources when
showLoading()is called (so no eager load happens). No fix needed; the bandwidth in the HAR was a false alarm. Confirm via DevTools Network panel.
loader.webm and loader.mp4 should NOT appear in the network waterfall of an initial landing-page load.
- Step 3: Build and verify
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build 2>&1 | tail -5
Expected: succeeds.
Run dev server. Open landing page. DevTools Network → filter “loader”. Reload. Confirm loader.webm and loader.mp4 do NOT appear in the request waterfall during initial load.
- Step 4: Smoke-test the loader still works elsewhere
- Step 5: Commit via odontox-commit-deploy
Task 8: Add Cache-Control entries for .avif and tighten *.png in _headers
Files:
- Modify:
ui/public/_headers:43-44
_headers file currently does not specify a Cache-Control rule for .avif files. Cloudflare Pages will fall back to a default short cache. Hashed bundle assets are already covered by /assets/* immutable, but root-level images like /ruby-icon.avif need their own rule.
The current /*.png rule (max-age=86400) is loose — pin it to images we expect to remain stable.
- Step 1: Edit
ui/public/_headers
.avif, .webm, .mp4, and SWR on .png.)
- Step 2: Build and verify headers file is copied
cd /Users/ssh/Documents/Beta-App/odontoX/ui && npm run build && grep -c '.avif' dist/_headers
Expected: 1.
- Step 3: Commit via odontox-commit-deploy
Task 9: Re-test PageSpeed + Lighthouse, record before/after
Files:- Create:
docs/qa/2026-05-14-mobile-pagespeed-results.md
https://odontox.io/).
- Step 1: Confirm latest changes are live
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://odontox.io/
Expected: 200, and the size should be small (the HTML doc only, ~5–10 KB).
Run: curl -s https://odontox.io/ | grep -c 'gtm-loader\|preload.*poppins-500'
Expected: at least 2 (confirms our deploy is live).
- Step 2: Run Lighthouse from the command line (or open Chrome DevTools)
- Step 3: Document results
docs/qa/2026-05-14-mobile-pagespeed-results.md with the format:
- Step 4: Commit the results doc
Self-Review Notes
- Spec coverage: Plan covers Priority 1 (JS deferral via Sentry/GTM), Priority 2 (page weight via media kill + lazy load), Priority 3 (third-party deferral), Priority 4 (LCP — font preload + critical CSS inline), Priority 5 (font-display swap is already in critical.css; we add missing preloads), Priority 7 (critical CSS inline). Priority 6 (TTFB/Brotli) is handled by Cloudflare Pages defaults — no code change needed. Priority 8 (HTTP/3) is already active.
- JS code splitting (Priority 1): Sentry is now dynamic-imported. Further marketing-bundle splitting (route-level lazy boundaries) is a larger refactor — out of scope for this plan; flag in results doc if Lighthouse still complains.
- Image LCP (Priority 4): The current LCP element is text (the h1 headline), not an image — confirmed by reading
PremiumHero.tsx. Font preload + critical CSS inline is the right fix. No image preload tag added because there is no above-the-fold image.
Execution Handoff
Plan complete and saved todocs/superpowers/plans/2026-05-14-mobile-pagespeed-optimization.md. Two execution options:
1. Subagent-Driven (recommended) — Dispatch a fresh subagent per task with two-stage review. Best for shipping incrementally.
2. Inline Execution — Execute tasks sequentially in this session with checkpoints. Faster if you want everything in one shot.
Which approach?
