Prerender every page; Lighthouse mobile 74 → 98 - #15
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
* 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
src/entry-server.jsx+scripts/prerender.mjs), hydrated in the browser.main.jsxhydrates only markup drawn for the current URL, so a 404-shim bounce renders fresh instead of mismatching.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
Revealleaves on-screen content alone on first load, and animatesopacitynotautoAlpha.autoAlphasetsvisibility: 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.<main>and calledscrollTo(0, 0)over scroll restoration and#anchors.animation-delay.Bugs found along the way
/services/was retitled "Page not found" withnoindexwhile showing the right page (my regression from Give every route its own title, description and link preview #9). Slash now normalised before first render.<h1>'s accessible name was "stories behindevery detail".ENOENTmasked the real error on a failed build..mjsbuild scripts, including this CI guard, were never linted.Accessibility & agents
h3→h2.llms.txtgenerated from the same data as the pages.toolautosubmit: an agent can fill it in, the visitor sends it.Guards
verify-seo.mjsnow also fails CI if a page isn't prerendered, its stylesheet is linked instead of inlined, orllms.txtis missing a page — negative-tested by emptying a root.scripts/serve-pages.mjs(npm run preview:pages) servesdist/the way GitHub Pages does.Not fixable on this host
cache-insight: GitHub Pages fixesCache-Control: max-age=600on everything. It affects repeat visits, not the scored metrics.