p4ni.

Build in Public

Fixing Web Font LCP in Astro: PageSpeed 83 → 99

· Updated · 12 min read

On this page

This site is about as simple as a site gets: static Astro, no client-side framework, no images above the fold, deployed to Cloudflare Workers. Desktop PageSpeed was 99. Mobile was 83.

Every point of that gap came from web fonts — but not in the way I expected, and the obvious fix (preload everything) made one metric worse while fixing another. Here’s the whole sequence, with the numbers from each step.

These measurements are from July 2026, against the Newsreader and Instrument Serif this site shipped at the time. It has since consolidated on Inter. The method and the formulas are unchanged — the fallback is now matched against Arial instead of Georgia.

BeforeAfter
Performance8399
Accessibility95100
FCP3.1 s1.6 s
LCP3.8 s1.6 s
Speed Index3.1 s1.6 s

1. Astro only inlines CSS under 4 KiB

The first Lighthouse item was render-blocking requests, est. savings 900 ms, pointing at a single 14.4 KB stylesheet. The critical path was HTML (4.6 KiB) → CSS (5.3 KiB transferred) — a full round trip before anything painted.

Astro’s default is build.inlineStylesheets: 'auto', which defers to Vite’s build.assetsInlineLimit4 KiB (4096 bytes) unless you’ve changed it. Anything larger gets a <link>. One shared stylesheet for the entire site is exactly the case where inlining wins regardless of size:

// astro.config.mjs
export default defineConfig({
  build: {
    inlineStylesheets: 'always',
  },
});

The homepage went from two requests to one. The render-blocking audit dropped to zero items, and the critical path’s max latency fell from 1,011 ms to 256 ms — the round trip, not the bytes, was the cost. (The 9.9 KiB of HTML and CSS together only came down later, in the next section; inlining moves bytes, it doesn’t remove them.)

If your stylesheet is genuinely large — a Tailwind build with per-page CSS, say — measure before copying this. Inlining duplicates the bytes into every HTML file and gives up cross-page caching. For a shared 10 KB sheet on a content site, one round trip saved is worth more.

2. Why a static blog had a 14 KB stylesheet

Fonts. Sixteen @font-face blocks were 57% of the file, and eleven of them were for alphabets this site will never render.

The cause is how Fontsource packages get imported. This looks fine:

import '@fontsource/instrument-serif';
import '@fontsource-variable/newsreader';
import '@fontsource-variable/jetbrains-mono';

Each bare import pulls in every subset the package ships — cyrillic, greek, vietnamese, latin-ext. Browsers won’t download the woff2 files (that’s what unicode-range is for), but the declarations still ship in your CSS on every page. One of them had even been small enough for Vite to inline as a data URI: 2.7 KB of base64 for the Cyrillic Extended subset of JetBrains Mono, in a stylesheet served to readers of an English-only site.

Static Fontsource packages ship per-subset stylesheets, so those are a one-line fix:

import '@fontsource/instrument-serif/latin-400.css';
import '@fontsource/instrument-serif/latin-400-italic.css';

The @fontsource-variable/* packages don’t — they split by axis (wght.css, wght-italic.css), not by subset, so there’s no latin-only entry point. I copied the latin blocks into a local src/styles/fonts.css and imported that instead. Same trap I ran into generating OG images: the variable packages are shaped differently from the static ones, and it’s always worth looking inside node_modules rather than assuming.

Result: 15 woff2 files → 5, 16 @font-face blocks → 5.

3. The animation that was hiding my LCP element

With the stylesheet inlined, FCP halved. LCP didn’t move — and the LCP element was wrong.

Lighthouse reported the largest contentful paint as <a class="wordmark"> — the small p4ni. logo in the header — with 1,270 ms of “element render delay”. The genuinely large text on the page, the homepage <h1> and the lede under it, wasn’t a candidate at all. This was why:

@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

.fade-in {
  animation: rise 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) both;
}

animation-fill-mode: both applies the from keyframe before the animation starts, so with an animation-delay: 0.05s the <h1> sits at opacity: 0 until then. The LCP spec skips a text node outright when its opacity is zero at paint time. A faded-in element only becomes a candidate when something repaints it — so it either loses the slot to whatever was visible early, or wins it at a much later timestamp. In my case the winner was a 1.7 rem logo.

Staggered entrances are fine below the fold. On the hero they trade your best LCP candidate for a worse one. I dropped .fade-in from the heading and lede, and kept it for the sections further down the page.

4. font-display: swap and the CLS it was hiding

Removing the animation immediately surfaced a layout shift that had been invisible behind it:

score 0.1713  <section class="latest">
   cause: Web font loaded  newsreader-latin-wght-normal.woff2
   cause: Web font loaded  instrument-serif-latin-400-italic.woff2
   cause: Web font loaded  jetbrains-mono-latin-wght-normal.woff2
   cause: Web font loaded  instrument-serif-latin-400-normal.woff2

Standard font-display: swap behavior: the page paints in a system fallback, the real fonts arrive, everything reflows. The animation had been masking it — the shifting content was still fading in, so it never counted.

The obvious fix is to make the fonts arrive before the first paint:

<link rel="preload" as="font" type="font/woff2" href={fontUrl} crossorigin />

Four preloads, and CLS went back to 0. Deployed it. Mobile PageSpeed went 83 → 88, FCP 3.1 s → 1.5 s, Speed Index 3.1 s → 1.5 s.

LCP: 3.8 s → 3.9 s. No improvement at all.

5. Preload was never going to fix that LCP

My first explanation was that preloading had put 141 KB of fonts in front of the largest paint. That’s wrong, and the mistake is worth spelling out, because it points at the actual fix.

Under font-display: swap the text paints immediately in the fallback — the font download never blocks it. web.dev is explicit: with any font-display other than auto or block, “LCP won’t be blocked on an additional network request.” So the hero <p class="lede"> was painted early either way.

What was late was the repaint. When the real font swaps in, the lede re-lays out at a different size, and the browser records that as a new, later largest paint. LCP wasn’t waiting on bytes; it had been handed off to the swap. Preloading only changes when the swap happens — and with the stylesheet already inlined, the fonts were being requested at almost the same moment anyway, which is exactly why 3.8 s became 3.9 s instead of anything better.

The way out is to stop the swap from being an LCP event at all. Lighthouse says so in the font-display insight, easy to skim past:

swap can be further optimized to mitigate layout shifts with font metric overrides.

Instead of making the real font arrive first, make the fallback occupy the same space — then the swap is invisible and there’s nothing to hide.

Four CSS descriptors do it: size-adjust scales the fallback’s glyphs, and ascent-override / descent-override / line-gap-override pin its line box. The ratios come from the font files:

size-adjust      = target avg char width ÷ fallback avg char width
ascent-override  = target ascent ÷ upm ÷ size-adjust
descent-override = |target descent| ÷ upm ÷ size-adjust

Dividing by size-adjust isn’t a fudge — per MDN it scales “overrides provided by @font-face descriptors” as well as glyphs, so the division cancels it back out.

Don’t compute the width ratio yourself

I did, from OS/2.xAvgCharWidth. Measured in the browser afterwards, my fallback rendered the homepage <h1> 26.8% wider than the real font — the exact thing size-adjust exists to prevent. The OpenType spec warns about this directly:

Applications should not use xAvgCharWidth for determining actual glyph advance widths.

The definition changed between table versions — OS/2 v0–v2 is a frequency-weighted average over lowercase latin, v3+ is a plain arithmetic mean over every glyph — and fonts get bumped to a newer version without the value being recomputed. Newsreader reports a v4-style 1057/2000. Georgia declares v3 but still carries a legacy weighted 901/2048. Divide one by the other and you get a confident, precise, meaningless number:

Newsreader → Georgiasize-adjust
via OS/2.xAvgCharWidth120.13%
via measured advance widths96.12%

Georgia is a famously wide face. Any ratio claiming Newsreader is 20% wider than Georgia should have stopped me. Instrument Serif was worse — 95.92% shipped against a true 76.49%.

The check takes thirty seconds and I should have run it before publishing: render the same string in the real family and in the fallback family, and compare the widths.

const measure = (family) => {
  const s = document.createElement('span');
  s.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;
                     font-size:67px;font-family:${family}`;
  s.textContent = document.querySelector('h1').textContent;
  document.body.appendChild(s);
  const { width } = s.getBoundingClientRect();
  s.remove();
  return width;
};

await document.fonts.ready;
measure("'Newsreader Georgia Fallback'") / measure("'Newsreader Variable'"); // want ~1.00

Mine returned 1.252. It now returns 1.002.

Capsize measures real latin advance widths and calls the result xWidthAvg; @capsizecss/metrics ships precomputed values for the system fonts, so you never need a copy of Georgia:

import { fromFile } from '@capsizecss/unpack/fs';
import { entireMetricsCollection as sys } from '@capsizecss/metrics/entireMetricsCollection';

const t = await fromFile('newsreader-latin-wght-normal.woff2');
const f = sys.georgia;

const sizeAdjust = t.xWidthAvg / t.unitsPerEm / (f.xWidthAvg / f.unitsPerEm);
const ascent = t.ascent / t.unitsPerEm / sizeAdjust;
const descent = Math.abs(t.descent) / t.unitsPerEm / sizeAdjust;

One face per fallback font

size-adjust is derived against one specific fallback. My original block listed four local() sources under a single ratio, which is self-defeating: the moment it resolves to anything but Georgia, the widths are wrong again. Split them.

@font-face {
  font-family: 'Newsreader Georgia Fallback';
  src: local('Georgia');
  size-adjust: 96.12%;
  ascent-override: 76.47%;
  descent-override: 27.57%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Newsreader Times Fallback';
  src: local('Times New Roman'), local('Liberation Serif'), local('Tinos'), local('Nimbus Roman');
  size-adjust: 105.48%;
  ascent-override: 69.68%;
  descent-override: 25.12%;
  line-gap-override: 0%;
}

Grouping several local() names in one face is only safe when they’re metric-compatible clones — Liberation Serif, Tinos, and Nimbus Roman are all drop-in replacements for Times New Roman, so one ratio covers the set. That distinction matters more than it seems like it should: Lighthouse’s environment isn’t macOS, and a fallback family that resolves to nothing silently does nothing.

Then slot them in ahead of the generic keyword, most likely first, and delete the preloads:

--font-body: 'Newsreader Variable', 'Newsreader Georgia Fallback',
  'Newsreader Times Fallback', Georgia, serif;

6. Results

MetricStart+ inline CSS, latin subsets, no hero animation, preload+ metric-matched fallbacks
Performance838899
FCP3.1 s1.5 s1.6 s
LCP3.8 s3.9 s1.6 s
Speed Index3.1 s1.5 s1.6 s
CLS0 *00.034 †

* The starting 0 was an illusion. The shift was always there at 0.171; it only became measurable once the entrance animation stopped covering it (§4).

† And this 0.034 turned out to be my own bug, not the technique’s floor — see below. It’s ~0.000 with the corrected metrics.

LCP and CLS are both Core Web Vitals, which is why PageSpeed Insights weighs them so heavily and why they end up mattering for search as well as for readers. That 0.034 is well inside the 0.1 “good” threshold for CLS, and it bought 2.3 seconds of LCP against the preload build.

CLS didn’t return to zero in that pass, and for a while I explained it away as the price of the technique: size-adjust only matches average character width, so line-wrap positions in large headings should still drift a little. That explanation was wrong. It was the 25% error.

So I rebuilt both versions and ran Lighthouse against each locally, three runs apiece, changing nothing but the fallback @font-face blocks:

CLSFCPLCPSpeed Index
OS/2.xAvgCharWidth metrics0.03351,803 ms1,803 ms1,803 ms
Capsize metrics0.00041,803 ms1,803 ms1,803 ms

Identical paint timings — a metric override moves nothing onto or off the network — and the layout shift essentially gone. These are local runs, so the absolute timings aren’t comparable to the PageSpeed table above; the useful part is that 0.0335 lands on the 0.034 PageSpeed measured against production, which suggests the 0.0004 is worth believing too.

Correctly matched, the fallback is within 1.1% of Instrument Serif and 0.2% of Newsreader on the homepage <h1>. Wrongly matched, it was 26.8% and 25.2% too wide — a quarter of the line, on every heading, until the real font landed.

Accessibility went 95 → 100 in the same pass, unrelated to fonts: two colors in the light theme sat at 3.5:1 and 3.8:1 against the raised card surface. Darkening #857c6f → #726a5c and #d44a1a → #c03f0e cleared 4.5:1 without changing the palette’s character.

What I’d do differently

Reach for metric overrides before preload. Preloading hides a font swap by racing it, which does nothing for you when LCP is already deferred to the swap. Metric-matched fallbacks cost about 40 lines of CSS and no bandwidth at all. Preload afterwards only if something still lands late.

Check what your entrance animations are doing to LCP. Any opacity: 0 starting state — animation-fill-mode: both, a .is-visible class toggled by IntersectionObserver, most scroll-reveal libraries — takes that element out of contention until something repaints it. It’s a silent trade, and the audit only tells you which element did win, never which one should have.

Use the library. I hand-rolled the metric math from a formula I’d read, got a plausible number out of a field the spec tells you not to read, and shipped it. @capsizecss/unpack and fontaine exist because this is fiddlier than it looks. The formula in a blog post is the easy half; the input values are where it goes wrong.

Look inside node_modules. Two of the problems here were packages doing something reasonable that didn’t match my assumptions: Astro’s 4 KiB inlining threshold, and Fontsource variable packages splitting by axis rather than subset. Both are documented. Neither is what you’d guess.

That’s the part I like about this class of fix: it’s 40 lines you can carry from project to project, and it costs the reader nothing.