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.

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.

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)RequestsFont bytes (above fold)
Three static woff2 weights, full charset3210 KB
One variable woff2, subset to Latin144 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.

Metric-matched fallback versus an unmatched one Two scenarios, each shown before and after the web font swaps in. With a default fallback the heading box is a different height than the web font, so the content below it jumps by fourteen pixels and CLS is 0.18. With a metric-matched fallback the fallback box is the same height as the web font, the content below stays put, and CLS is 0.00. Why size-adjust removes the swap shift Fallback shown Web font shown Default fallback Heading loads Heading 14px jump CLS 0.18 content reflows Metric- matched Heading loads Heading stays put CLS 0.00 no reflow size-adjust and the ascent/descent overrides make the fallback box the same height as the web font, so nothing below it moves.
The heading box holds the same pixels before and after the swap only when the fallback is metric-matched; a default fallback is a different height, so every line below it jumps when the real font arrives.

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.
Where the font budget goes on a first visit Ranked bars: a hosted font stylesheet costs 310 milliseconds before the font request begins, self-hosting removes 180 of that, subsetting removes a further 90 milliseconds of transfer, and metric overrides remove the layout shift entirely at no byte cost. Where the font budget goes on a first visit Hosted stylesheet extra DNS + TLS + CSS 310 ms before the font request Self-hosted same connection as the page 130 ms + subset to 31 KB fewer bytes on the wire 58 ms + metric overrides no reflow on swap 58 ms · CLS 0.00 Shared linear scale · time from navigation start to the web font painting, fast 3G, mid-tier device
The first three rows are latency work; the fourth costs nothing and is what removes the layout shift.

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