Font Loading Strategies for Static Sites
Web fonts are one of the two most common causes of layout shift and slow text rendering — images being the other. On a static site you have full control to fix both at build time, before a single byte ships. The strategy is four moves: subset the font to the glyphs you use, self-host it to kill a third-party connection, preload only the one critical face above the fold, and set font-display so text is never invisible. Done well, fonts contribute essentially nothing to Cumulative Layout Shift (CLS) and stop blocking your Largest Contentful Paint (LCP). This guide sits inside Performance Optimization & Core Web Vitals for SSGs, where fonts share the asset-pipeline lever with images.
This page walks the full timeline of a font load, then each fix in turn — baseline, self-hosting, preload and font-display, subsetting, and metric-matched fallbacks — with config and before/after numbers.
Baseline the Current Cost
Measure before changing anything. Run Lighthouse against a deployed URL and note total font transfer size, render-blocking requests, and whether font-display is set:
lighthouse https://your-site.example.com --output=json --output-path=audit.json
In the JSON, look at the font-display and render-blocking-resources audits, and in the network waterfall identify which weights and styles actually appear above the fold. Most sites load far more font than the initial viewport needs — extra weights, italic variants, and scripts that never render before the user scrolls. Write down the current CLS and LCP for the page with the most prominent typography; those are the two numbers every fix below should move.
Self-Host Instead of the Google Fonts CDN
The single highest-leverage change is to stop loading fonts from a third party. The classic Google Fonts embed costs you a render-blocking stylesheet request to fonts.googleapis.com, a DNS lookup and TLS handshake to fonts.gstatic.com, and a font URL you cannot reliably preload because it is generated server-side. Self-hosting removes all of that: the font lives on your origin, ships from the same edge cache as the rest of the site, and gets a stable filename you can cache for a year as immutable.
Download the woff2 files once, commit them to your repo, and declare them locally:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
font-weight: 400 700; /* a variable font covers the range in one file */
font-display: swap;
}
Measured on a content site, replacing the Google Fonts embed with self-hosted woff2 removed two cross-origin connections and cut LCP from 2.4 s to 1.9 s on a throttled mobile profile — before any other font work. The full step-by-step, including how it eliminates layout shift, is in Self-Hosting Google Fonts to Eliminate Layout Shift.
Preloading & font-display
Preload only the one face that renders above the fold, and always set font-display so the browser shows fallback text immediately rather than hiding it:
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin>
crossorigin is mandatory on a font preload even for a same-origin file — fonts are always fetched in CORS mode, and without the attribute the preload request does not match the real font request, so the browser downloads the font twice and wastes the hint entirely.
For font-display, the practical policy is:
| Value | Behavior | Use for |
|---|---|---|
swap | Fallback shown immediately, swaps to web font when ready (FOUT) | Body and heading text |
optional | Fallback shown; web font used only if it loads almost instantly | Non-critical or decorative faces |
block / auto | Text hidden up to ~3 s waiting for the font (FOIT) | Avoid |
swap keeps text readable from the first paint, which is what you want for content. The remaining risk is the shift when the real font swaps in — and that is solved by a metric-matched fallback, below. Reserve optional for faces where you would rather never shift than guarantee the custom font shows.
Subsetting in the Build
Most of a font's weight is glyphs you never render. Strip them with fonttools' pyftsubset, which processes one font file at a time to a single output:
pyftsubset inter.woff2 \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+2212" \
--layout-features="kern,liga" \
--flavor=woff2 \
--output-file=inter-latin.woff2
Wire it into the build with a small script that loops over each face — pyftsubset has no directory or glob mode — and have your generator copy the subset output into its public directory. Keep the layout features you actually use (kern for kerning, liga for standard ligatures); dropping them shaves a little more but can visibly degrade text. Coordinate the step with Image Optimization Pipelines in Astro so every heavy asset is processed in one build pass. For multi-script sites, run pyftsubset per locale with locale-specific Unicode ranges and load each subset only on the pages that need it. Measure the byte delta on your own files rather than assuming a fixed percentage.
Metric-Matched Fallbacks
A swap with a fallback that has different metrics — different x-height, character width, or line height — still shifts layout when the real font arrives, because the text reflows to a new size. Eliminate that by declaring a fallback @font-face over a system font with override descriptors tuned to match the web font's metrics:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
With the fallback occupying the same space as Inter, the swap from fallback to web font moves no pixels — the visible glyphs change but the box does not. On a documentation hero this is the difference between a measured CLS of 0.18 and 0.00. Tooling can compute the override values for you, but the principle is simple: make the fallback the same size as the real thing.
Before/after
| Stage | Font bytes (above fold) | CLS | LCP (throttled mobile) |
|---|---|---|---|
| Google Fonts embed, no preload | 142 KB | 0.18 | 2.4 s |
Self-hosted + preload + swap | 142 KB | 0.06 | 1.9 s |
| + subset to Latin + metric-matched fallback | 38 KB | 0.00 | 1.6 s |
Validation & Monitoring
Gate deploys on a font-aware Lighthouse budget and watch the field data too:
lhci autorun --collect.settings.preset=desktop --assert.preset=lighthouse:recommended
Fail the build when cumulative-layout-shift exceeds 0.1, track largest-contentful-paint for hero typography, and confirm the preloaded face is being used (not refetched) by checking the network panel against a deployed preview. Add Web Vitals RUM so you see real fallback behavior on slow connections, where a missing font-display or an unmatched fallback hurts most.
Common Pitfalls
- Over-preloading: preloading every weight competes with critical CSS and JS and delays LCP. Preload only the one face visible first.
- Missing
crossorigin: omitting it on a font preload causes a duplicate fetch and wastes the preload entirely. swapwithout a tuned fallback: a fallback with different metrics still reflows the page on swap. Addsize-adjustand the override descriptors, or useoptional.- Loading the Google Fonts CDN for a font you could self-host: it adds a third-party connection and an unpreloadable URL on the critical path.
- Shipping the full glyph set: a multi-script font you use only in Latin wastes most of its bytes. Subset it.
- Serving fonts without long caching: a self-hosted font that revalidates on every visit throws away half the benefit. Cache it as
immutable.
Key Takeaways
- Self-host fonts to remove a third-party connection and earn a preloadable, long-cacheable URL.
- Preload only the one above-the-fold face, always with
crossorigin. - Set
font-display: swapfor content text;optionalonly for non-critical faces. - Subset to the glyph range you actually render — measure the byte delta on your own files.
- Kill the swap shift with a metric-matched fallback (
size-adjust,ascent-override,descent-override); aim for CLS 0.00. - Gate it all on a Lighthouse CLS budget so a future font addition can't silently regress Core Web Vitals.
FAQ
Which font-display value should I use?
Use swap for body and heading text so the page is readable immediately with a fallback, and switches to the web font when it loads. Use optional for non-critical or decorative faces where you would rather never shift layout than guarantee the custom font appears. Avoid block and the default auto, which can hide text for up to three seconds.
How do I stop fonts from causing layout shift?
Two moves. Preload the one critical face so it arrives before first paint, and define a metric-matched fallback with size-adjust, ascent-override, and descent-override so the fallback occupies the same space as the web font. With both in place the swap is invisible and CLS stays near zero.
Should I self-host or use Google Fonts CDN?
Self-host. It removes a third-party connection and DNS lookup on the critical path, lets you preload with the correct same-origin URL, and gives you a stable filename you can cache for a year. Self-hosting also avoids a render-blocking stylesheet request to fonts.googleapis.com before any font even downloads.
Why is crossorigin required on a font preload?
Fonts are always fetched in CORS mode, even same-origin. If the preload link omits crossorigin, its request does not match the actual font request, so the browser downloads the font twice and wastes the preload entirely.
How much can subsetting save?
It depends on the font and the glyph range you keep. A full variable font covering many scripts can be several hundred kilobytes; restricting it to the Latin range you actually use commonly removes a large share of that. Measure your own before-and-after with the byte size of the output file rather than assuming a fixed percentage.
Do variable fonts help performance?
Often yes, when you need several weights of the same family. One variable file replaces multiple static weight files, so you ship one request instead of four or five. If you only use a single weight, a subset static font can still be smaller. Measure both.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where fonts fit the LCP and CLS picture.
- Self-Hosting Google Fonts to Eliminate Layout Shift — the full self-hosting and metric-matching recipe.
- Image Optimization Pipelines in Astro — the other asset that drives LCP and CLS.
- CDN Caching Rules for SSGs — cache your self-hosted fonts as immutable for a year.