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) — the hero text or heading a slow font holds hostage is frequently the LCP element itself. 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.
Variable Fonts & Format Strategy
Two build decisions compound with subsetting. First, ship woff2 and nothing else. Every browser you care about has supported it for years, and the older woff, ttf, and eot formats only exist to burn bytes and add fallback src entries you no longer need. Declare a single format:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
/* no woff/ttf/eot fallbacks — woff2 is universally supported */
}
Second, prefer one variable font over a pile of static weights when the design uses more than one weight. A variable file carries every weight along a single axis, so it replaces four or five separate requests with one — and after subsetting, one woff2 you can preload. If the page truly uses a single weight, a subset static file is still smaller; measure both on your own text.
You can also let the browser fetch only the subset a given page renders by splitting the font into ranges and tagging each @font-face with unicode-range. The browser downloads a range only when the page actually contains a glyph inside it:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+2000-206F; /* Latin */
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-greek.woff2') format('woff2');
unicode-range: U+0370-03FF; /* Greek — fetched only if used */
font-display: swap;
}
An English page never pays for the Greek block, and a page that quotes Greek pulls it lazily without a round trip to a CDN. Preload only the Latin range that renders above the fold; let the rest resolve on demand.
| Weight strategy (400/500/700) | Requests | Font bytes (above fold) |
|---|---|---|
Three static woff2 weights, full charset | 3 | 210 KB |
One variable woff2, subset to Latin | 1 | 44 KB |
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.
Decide the Policy Before the Files
Font work goes wrong when it starts with files. Decide four things first, and the implementation follows mechanically.
How many faces the design genuinely needs. Most documentation sites need one family at two weights, and ship four. Every additional face is another request racing the LCP image, and a variable font collapses the whole set into one file.
What happens during the swap window. swap with metric-matched fallbacks means text is readable immediately and never moves. optional means text is readable immediately and the web font may not be used at all on a slow connection. Both are defensible; block — invisible text while the font loads — almost never is on a content site.
Which single file, if any, is worth preloading. Preloading everything competes with the hero image for the first round trip, and the hero image is usually the LCP element. One body face is the right answer for most sites.
Whether the typeface is doing brand work at all. If it is not, font-display: optional plus a good system stack is a legitimate and considerably cheaper answer than any of the above, and it is worth putting on the table before the optimisation work starts.
What Changes on a Repeat Visit
Everything above describes a first visit, which is the right thing to optimise because it is where the cost is. It is worth being explicit about what happens afterwards, because it changes how much any of this matters.
On a repeat visit with a warm cache the font is already local: no request, no swap window, no layout shift, regardless of policy. That means font work is entirely first-visit work — and on a documentation site, where a large share of traffic arrives from search and reads one page, first visits dominate. On an internal wiki with the same twenty readers every day, the same optimisations are close to irrelevant.
Two practical consequences follow. Cache the font file immutably with a content hash in the filename, so the second visit never revalidates it and a subset change is picked up automatically. And weight the effort by your traffic mix: if analytics say four in five sessions are new, font loading is a genuine LCP lever; if four in five are returning, spend the time on interaction latency instead.
The one exception is a font that changes. A subset regenerated on every build with a content-hashed name is a new file each time the character set changes, which is correct but means some repeat visitors pay the first-visit cost again. Regenerating the subset only when the character set actually changes — rather than on every build — keeps that rare.
One more habit worth adopting: record the font policy in a comment at the top of the stylesheet — which faces, which weights, which display strategy and why. It is three lines, and it is the difference between a future contributor adding a fourth weight deliberately and adding it because nobody wrote down that the site ships one on purpose.
Treat any change to the font stack as a performance change, not a design one: it moves LCP, it can move CLS, and it is worth re-measuring both before merging. A typeface swap that looks like a visual decision is frequently the largest single performance change a content site makes in a quarter.
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.
- Subsetting Variable Fonts for Faster First Render — 128 KB down to 31 KB in the build.
- Self-Hosting Google Fonts to Eliminate Layout Shift — the full self-hosting and metric-matching recipe.
- Largest Contentful Paint Optimization for Static Sites — why hero text is so often the LCP element a slow font delays.
- 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.