perf: fix mobile LCP (85 -> ~98) - #64
Merged
Merged
Conversation
Mobile Lighthouse scored 85 against 100 on desktop, and LCP was the whole gap: 4.7s, scoring 0.32, while CLS was 0 and TBT 70ms. The trace showed two LCP candidates, both the hero <h1>: one at 2080ms at 83,931px2, then the same element again at 4744ms at 98,889px2. The heading is in the static HTML, so nothing was slow to arrive. What happened is that Domine is only discoverable after the render-blocking stylesheet parses, so it was requested ~1s late, arrived after first paint, and re-laid the heading into a taller box -- and a larger paint box registers a second LCP candidate. - Preload the Domine latin subset (the only subset English content pulls) from ssg.config.ts, using the hashed filename from the client build. The font now starts loading alongside the stylesheet instead of after it. - Declare the faces in styles.css instead of importing @fontsource's stylesheet, so font-display can be `optional`: the font is used when it is ready for the first layout and otherwise sits out that page load, so typography can never reset LCP. Verified Domine still renders on a cold Slow-4G load (first-line width 291px, against 251px with the font blocked); the fallback path costs no layout shift when it does engage. - Defer gtag.js to onload. It is ~170KB and, requested from the head, it contended for bandwidth across the whole critical window. The inline stub is what route-analytics calls and dataLayer queues events until the library drains them, so no analytics are lost.
The marketing home page was downloading the entire documentation corpus. _routes.js was 724KB raw / 235KB gzip, and 88% of it (636KB of 722KB) was long prose string literals: catalog.ts imports content-overrides.ts for heading bodies, and _routes.tsx imports the catalog eagerly to build the route registry. That chunk is also the last link in the critical request chain, so parsing it is what delayed the LCP repaint. Of content-overrides.ts, headingOverrides was 521KB while lateHeadingOverrides and descriptionOverrides together were ~11KB, so the split is clean: - Move headingOverrides into heading-bodies.ts with a resolveHeadingBodies() helper, imported only by page.tsx (already a lazily loaded route chunk) and the build-time markdown generator. - catalog.ts keeps every heading's id and title, which the table of contents and the search index both need eagerly, and resolves only the cheap late overrides inline. _routes.js drops to 214KB raw / 64KB gzip, a 171KB gzip reduction on the critical path for every route. Docs pages are unchanged in total bytes -- the prose moves into their page chunk -- so this trades nothing away. Behaviour is preserved rather than assumed: headingOverrides and lateHeadingOverrides have zero overlapping keys, so moving where precedence is applied is a no-op, and recomputing the old single-expression result for all 1128 headings (1020 of which carry a hand-written body) gives 0 mismatches and no id or title drift. Local mobile Lighthouse: 80 -> 98, LCP 5.0s -> 1.8s with a single LCP candidate.
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.
The problem
Mobile Lighthouse scored 85 against 100 on desktop. LCP was the entire gap — 4.7 s, scoring 0.32 — while CLS was 0, TBT 70 ms, and Speed Index 2.3 s.
The trace showed two LCP candidates, both the hero
<h1>:The heading is in the static HTML, so nothing was slow to arrive. Domine is only discoverable after the render-blocking stylesheet parses, so it was requested ~1 s late, landed after first paint, and re-laid the heading into a taller box — and a larger paint box registers a second LCP candidate. Desktop scored 100 because without the 4× CPU throttle and Slow-4G the font lands at first paint, so the second candidate never happens.
Preloading the font alone did not fix it: the font then arrived at 1747 ms (before FCP at 1801 ms) but the swap still painted at 4966 ms, because the repaint was waiting on the main thread. That pointed at the second problem.
The second problem
The marketing home page was downloading the entire documentation corpus.
_routes.jswas 724 KB raw / 235 KB gzip, and 88% of it (636 KB of 722 KB) was long prose string literals —catalog.tsimportscontent-overrides.tsfor heading bodies, and_routes.tsximports the catalog eagerly to build the route registry. That chunk is the last link in the critical chain, so parsing it is what held up the repaint.Changes
Commit 1 — typography and analytics
ssg.config.ts, using the hashed filename from the client build.styles.cssrather than importing@fontsource's stylesheet, sofont-displaycan beoptional— the font is used when ready for the first layout and otherwise sits out that page load, so typography can never reset LCP.gtag.jstoonload. It's ~170 KB and was contending for bandwidth across the whole critical window. The inline stub is whatroute-analyticscalls, anddataLayerqueues events until the library drains them, so no analytics are lost.Commit 2 — chunk split
headingOverrides(521 KB) moves toheading-bodies.tswith aresolveHeadingBodies()helper, imported only bypage.tsx(already a lazy route chunk) and the build-time markdown generator.lateHeadingOverridesanddescriptionOverrides(~11 KB combined) stay put.catalog.tskeeps every heading'sidandtitle, which the table of contents and search index need eagerly._routes.js: 724 KB → 214 KB raw, 235 KB → 64 KB gzip — 171 KB gzip off the critical path for every route. Docs pages are unchanged in total bytes (the prose moves into their page chunk), so this trades nothing away.Result
Local mobile Lighthouse 80 → 98, LCP 5.0 s → 1.8 s, with a single LCP candidate. TBT 70 ms → 40 ms.
Verification
Behaviour is proven rather than assumed:
headingOverridesandlateHeadingOverrideshave zero overlapping keys, so moving where precedence is applied is a no-op.document.fonts.check()is not sufficient here (it reports the file loaded, not that it painted), so this measures rendered glyph advance instead.llms-full.txtintact at 2.2 MB; override prose still present in generated docs HTML and the.mdalternates.fmt:check, lint, typecheck, 40 unit tests, build + 288 SSG routes, 2 static-output contracts, 41 Playwright layout tests — all passing.Tradeoff worth knowing
font-display: optionalmeans that on a genuinely slow first load the hero can render in the fallback serif, with Domine appearing on the next navigation from cache. That is the deliberate trade for an LCP that typography cannot reset, and it costs no layout shift when it engages. Under realistic emulation Domine renders every time; it only fell back under Lighthouse's harsher simulation. Say the word if you'd rather haveswapback and accept the LCP risk.Not addressed
Cache-Control: max-age=600and offers no header control, so this needs a different host or a CDN in front. Repeat visits only; it does not affect LCP.