Skip to content

perf: fix mobile LCP (85 -> ~98) - #64

Merged
smiggleworth merged 2 commits into
mainfrom
perf/mobile-lcp
Sep 12, 2026
Merged

smiggleworth merged 2 commits into
mainfrom
perf/mobile-lcp

Conversation

@smiggleworth

Copy link
Copy Markdown
Contributor

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>:

Time Size What
2080 ms 83,931 px² first paint, fallback serif
4744 ms 98,889 px² same element, 18% bigger box

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.js was 724 KB raw / 235 KB gzip, and 88% of it (636 KB of 722 KB) was long prose string literalscatalog.ts imports content-overrides.ts for heading bodies, and _routes.tsx imports 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

  • Preload the Domine latin subset from ssg.config.ts, using the hashed filename from the client build.
  • Declare the faces in styles.css rather than importing @fontsource's stylesheet, so font-display can be optional — the font is used when ready for the first layout and otherwise sits out that page load, so typography can never reset LCP.
  • Defer gtag.js to onload. It's ~170 KB and was contending 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.

Commit 2 — chunk split

  • headingOverrides (521 KB) moves to heading-bodies.ts with a resolveHeadingBodies() helper, imported only by page.tsx (already a lazy route chunk) and the build-time markdown generator. lateHeadingOverrides and descriptionOverrides (~11 KB combined) stay put.
  • catalog.ts keeps every heading's id and title, 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:

  • headingOverrides and lateHeadingOverrides have zero overlapping keys, so moving where precedence is applied is a no-op.
  • Recomputing the old single-expression body for all 1,128 headings (1,020 with hand-written bodies) gives 0 mismatches and no id/title drift.
  • Domine still renders on a cold Slow-4G load — first-line width 291 px, against 251 px with the font blocked. 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.txt intact at 2.2 MB; override prose still present in generated docs HTML and the .md alternates.
  • 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: optional means 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 have swap back and accept the LCP risk.

Not addressed

  • 10-minute cache lifetime on hashed assets (347 KB flagged). GitHub Pages fixes Cache-Control: max-age=600 and offers no header control, so this needs a different host or a CDN in front. Repeat visits only; it does not affect LCP.
  • The trace also shows ~13 s of cumulative long tasks running out to 16 s. TBT only measures FCP→TTI so it still scores well, but that post-load main-thread work deserves its own investigation.

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.
@smiggleworth
smiggleworth merged commit 78b0e53 into main Sep 12, 2026
1 check passed
@smiggleworth
smiggleworth deleted the perf/mobile-lcp branch September 12, 2026 12:03
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