Skip to content

Prerender every page; Lighthouse mobile 74 → 98 - #15

Merged
laveshparyani merged 1 commit into
mainfrom
perf/prerender-and-audit-fixes
Oct 1, 2026
Merged

laveshparyani merged 1 commit into
mainfrom
perf/prerender-and-audit-fixes

Conversation

@laveshparyani

Copy link
Copy Markdown
Collaborator

Why

Lighthouse 13.5 on the live site, mobile: Performance 74, LCP 4.4 s, FCP 3.4 s.

The image wasn't the problem — TTFB 402 ms, then 2.5 s of element render delay. The body shipped as an empty <div id="root">, so nothing painted until ~120 kB of JS ran, and the hero intro then started by hiding the lede — which is the LCP element on a phone. Anything that doesn't run JS, including most AI crawlers, saw no content at all.

Results (local, GitHub-Pages-equivalent server)

Perf A11y BP SEO Agentic LCP FCP
Mobile — before (live) 74 98 100 100 100 4.4 s 3.4 s
Mobile — after 98 100 100 100 100 2.4 s* 0.7 s
Desktop — before (live) 98 94 100 100 67 0.9 s 0.8 s
Desktop — after 100 100 100 100 100 0.5 s 0.3 s

* Simulated. On localhost the image paints after load, so Lighthouse's simulator chains the JS download into LCP; it stays at 2.43 s across very different network orders. Under real DevTools throttling (slow 4G + 4× CPU) mobile measures Perf 99, LCP 2.1 s. Every published route scores 99/100/100/100/100.

Rendering

  • Prerendered at build time (src/entry-server.jsx + scripts/prerender.mjs), hydrated in the browser. main.jsx hydrates only markup drawn for the current URL, so a 404-shim bounce renders fresh instead of mismatching.
  • CSS inlined — it was its own render-blocking request.
  • Fonts self-hosted (Fontsource, OFL) with metric-matched fallbacks computed from the font files, replacing a render-blocking Google Fonts stylesheet on two extra origins.
  • Module script fetchpriority="low" — the page no longer needs it to show anything. Measured FCP 1.37 → 0.66 s.

Motion — never hide what's already painted

  • Hero intro is CSS from the first frame; only the headline animates.
  • Reveal leaves on-screen content alone on first load, and animates opacity not autoAlpha. autoAlpha sets visibility: hidden, which removed every below-fold heading from the accessibility tree — the real cause of the heading-order failure. Screen-reader heading navigation, in-page search and AI agents all lost most of the page until it was scrolled to.
  • Page transition no longer runs on first mount, where it hid all of <main> and called scrollTo(0, 0) over scroll restoration and #anchors.
  • Reduced motion now also zeroes animation-delay.

Bugs found along the way

  • /services/ was retitled "Page not found" with noindex while showing the right page (my regression from Give every route its own title, description and link preview #9). Slash now normalised before first render.
  • The <h1>'s accessible name was "stories behindevery detail".
  • Every route preloaded the homepage hero image.
  • The image pipeline warned about upscales and made them anyway — now caps at source width and deletes stale variants (1.6 MB → 1.0 MB).
  • The 404 plugin's ENOENT masked the real error on a failed build.
  • .mjs build scripts, including this CI guard, were never linted.

Accessibility & agents

  • WhatsApp button contrast 2.89 → 4.68:1. Footer headings h3 → h2.
  • llms.txt generated from the same data as the pages.
  • WebMCP declarative annotations on the enquiry form — deliberately without toolautosubmit: an agent can fill it in, the visitor sends it.

Guards

verify-seo.mjs now also fails CI if a page isn't prerendered, its stylesheet is linked instead of inlined, or llms.txt is missing a page — negative-tested by emptying a root. scripts/serve-pages.mjs (npm run preview:pages) serves dist/ the way GitHub Pages does.

Not fixable on this host

cache-insight: GitHub Pages fixes Cache-Control: max-age=600 on everything. It affects repeat visits, not the scored metrics.

Lighthouse on the live site, mobile: Performance 74, LCP 4.4 s, FCP 3.4 s.
The image was not the problem — TTFB 402 ms, then 2.5 s of "element render
delay". The body shipped as an empty <div id="root">, so nothing painted until
~120 kB of JavaScript had run; and the hero intro then began by hiding the
lede, which is the LCP element on a phone. Anything that does not run JS —
most AI crawlers — saw no content at all.

Rendering
- Every route is prerendered at build time (src/entry-server.jsx +
  scripts/prerender.mjs) and hydrated in the browser. main.jsx hydrates only
  markup drawn for the current URL, so a 404-shim bounce renders fresh.
- CSS is inlined: it was a render-blocking request of its own.
- Fonts are self-hosted (Fontsource, OFL) with metric-matched fallbacks
  computed from the font files, replacing a render-blocking Google Fonts
  stylesheet on two third-party origins.
- The module script is fetchpriority="low": the page no longer needs it to
  show anything, and it was splitting bandwidth with the hero image.

Motion
- The hero intro is CSS from the first frame; only the headline animates.
- Reveal leaves on-screen content alone on the first load, and animates
  opacity rather than autoAlpha. autoAlpha sets visibility: hidden, which
  removed every below-fold heading from the accessibility tree — the cause of
  the heading-order failure.
- The page transition no longer runs on first mount, where it faded all of
  <main> from hidden and called scrollTo(0, 0) over scroll restoration and
  #anchors.
- Reduced motion now also zeroes animation-delay.

Bugs found on the way
- /services/ rendered the right page while the head code retitled it "Page not
  found" and set noindex. The slash is now normalised before first render.
- The h1's accessible name was "stories behindevery detail".
- Every route preloaded the homepage hero image.
- The image pipeline warned about upscales and made them anyway; it now caps
  at source width and deletes stale variants (1.6 MB -> 1.0 MB).
- The 404 plugin's ENOENT masked the real error when a build failed.
- .mjs build scripts, including the CI guard, were never linted.

Accessibility and agents
- WhatsApp button 2.89:1 -> 4.68:1. Footer headings h3 -> h2.
- llms.txt generated from the same data as the pages.
- WebMCP declarative annotations on the enquiry form, without toolautosubmit:
  an agent fills it in, the visitor sends it.

verify-seo.mjs now also fails the build if a page is not prerendered, its
stylesheet is linked rather than inlined, or llms.txt is missing a page.
scripts/serve-pages.mjs serves dist/ the way GitHub Pages does, for testing.
@laveshparyani
laveshparyani merged commit ef8796e into main Oct 1, 2026
3 checks passed
@laveshparyani
laveshparyani deleted the perf/prerender-and-audit-fixes branch October 1, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant