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.

Font-loading timeline and its CLS impact Two timelines compared. The unoptimized path shows a render-blocking CSS request, FOIT with invisible text, a late font swap and a large layout shift. The optimized path shows a preloaded self-hosted font, FOUT with a metric-matched fallback, an early swap and near-zero CLS. Same font, two loading paths Unoptimized blocking CSS req FOIT — invisible text font downloads late swap → big shift CLS ≈ 0.18 Optimized preload (self-host) FOUT — fallback shown early swap, metric-matched CLS ≈ 0.00 Preload + a size-adjusted fallback turns a visible reflow into an invisible swap.
The unoptimized path blocks on a CSS request, hides text (FOIT), then swaps late into a big layout shift; the optimized path preloads a self-hosted font and swaps early into a metric-matched fallback, so CLS stays near zero.

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:

ValueBehaviorUse for
swapFallback shown immediately, swaps to web font when ready (FOUT)Body and heading text
optionalFallback shown; web font used only if it loads almost instantlyNon-critical or decorative faces
block / autoText 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

StageFont bytes (above fold)CLSLCP (throttled mobile)
Google Fonts embed, no preload142 KB0.182.4 s
Self-hosted + preload + swap142 KB0.061.9 s
+ subset to Latin + metric-matched fallback38 KB0.001.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.
  • swap without a tuned fallback: a fallback with different metrics still reflows the page on swap. Add size-adjust and the override descriptors, or use optional.
  • 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: swap for content text; optional only 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.

Static Site Generators in Production