Fast Font Loading Strategies

WebPerformanceTypographyFrontend
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

Our homepage text was invisible for 1.2 seconds while two WOFF2 files downloaded from Google Fonts. Lighthouse marked it as render-blocking. We self-hosted a subsetted variable font, added preload, set font-display: swap with a metric-matched fallback, and text appeared instantly with a barely noticeable swap 300ms later. LCP dropped by 900ms.

font-display strategies

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
Value Behavior Use when
swap Fallback immediately, swap when loaded Body text (recommended default)
optional Use font only if cached/loaded quickly Non-critical decorative fonts
fallback Brief invisible period (~100ms), then swap Headings where brief FOIT is acceptable
block Invisible until loaded (up to 3s) Avoid — causes FOIT

Preloading critical fonts

<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

The crossorigin attribute is required — fonts loaded via @font-face use CORS mode. Place preload in <head> before CSS that references the font.

Self-hosting vs. CDN

Self-host for performance and privacy:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

body {
  font-family: 'Inter', system-ui, sans-serif;
}

Benefits over Google Fonts CDN: no third-party DNS lookup, full control over subsetting, no GDPR tracking concerns, and same-origin caching with your other assets.

Subsetting

Include only the characters you need:

pyftsubset Inter-Regular.woff2 \
  --output-file=Inter-Latin.woff2 \
  --unicodes=U+0020-007F,U+00A0-00FF,U+2013-2014

For English-only sites, Latin subset reduces file size by 60-80%. Add character ranges when you expand to other languages.

Fallback font matching

Prevent layout shift by matching fallback 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;
}

Tools like Fallback Font Generator calculate override values automatically.

Variable fonts

One file, all weights:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

Compare: four static WOFF2 files at ~50KB each = 200KB total. One variable file = ~80KB.

Impact on Core Web Vitals

Fonts interact directly with LCP when the LCP element is text rendered in a web font. If the browser waits for the font before painting text, LCP delays by the full font download time. font-display: optional can improve LCP by allowing the browser to skip the web font entirely on slow connections. font-display: swap improves LCP versus block but may cause CLS if fallback and web font metrics differ — use size-adjust overrides to minimize shift.

Measure before and after with Web Vitals field data. Font changes that look fine locally on fast connections may still cause FOIT on 3G if preload is missing.

Loading strategy checklist

  1. Self-host WOFF2 files on your CDN
  2. Subset to required character ranges
  3. Preload the primary font used above the fold
  4. Set font-display: swap on all @font-face declarations
  5. Match fallback metrics with size-adjust overrides
  6. Use variable fonts to reduce file count
  7. Limit font families — two typefaces maximum for most sites

Google Fonts self-hosting workflow

Download from google-webfonts-helper or google-fonts npm packages. Host WOFF2 on your CDN. You keep the same font files without the extra DNS + connection to fonts.googleapis.com. Update @font-face paths and remove the external stylesheet link.

FOUT vs. CLS tradeoff

font-display: optional eliminates swap-induced CLS entirely but may show fallback on slow connections permanently. For brand-critical headings, accept a small CLS budget (≤0.05) with metric-matched fallbacks rather than skipping the web font.

Variable fonts loading

Single variable WOFF2 reduces requests vs multiple weights—preload once with font-weight: 100 900 range in @font-face. Subset variable fonts aggressively; full axis font may exceed multiple static files.

Legal and licensing

Verify font license allows self-hosting and subsetting—Adobe Fonts subscription may prohibit extraction. Open-source fonts (Inter, Source Sans) simplify pipeline.

Font loading API advanced

const font = new FontFace('Inter', 'url(/fonts/inter.woff2)');
await font.load();
document.fonts.add(font);

Imperative loading for comic-style progressive enhancement—show custom font only after load completes without FOIT using document.fonts.ready promise before measuring LCP text.

size-adjust fallback metrics

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
}

Metric-matched fallback cuts CLS from font swap. Subset WOFF2 to used glyphs — pyftsubset or glyphhanger after analyzing production traffic.

Preload only critical weight

Preloading every weight competes with LCP image — preload 400/700 only if those weights appear in hero text.

Practical follow-through (1)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (2)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (3)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (4)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Practical follow-through (5)

Ship the smallest vertical slice first — one route, one widget, one index configuration — with rollback documented before expanding scope. Baseline the user-visible metric this work protects (latency, recall, conversion, task success rate) for seven days before change and seven days after in your largest market.

Compare canary p75 to control before full rollout. Exercise edge paths manually: refresh, back navigation, double-submit, offline mode, and keyboard-only flows. When assumptions change — traffic doubles, vendor upgrades, org restructure — revisit whether the original design still fits; quiet periods hide drift until the next incident.

Resources

Frequently asked questions

What is the difference between FOIT and FOUT?

FOIT (Flash of Invisible Text) hides text until the web font loads, showing blank space. FOUT (Flash of Unstyled Text) shows fallback text immediately, then swaps to the web font when loaded. font-display: swap prevents FOIT by always showing fallback text first. The tradeoff is a visible font swap that can cause layout shift if fallback metrics differ.

Should I preload all web fonts?

Preload only fonts needed for above-the-fold content — typically one or two weights of your primary typeface. Over-preloading competes with LCP resources for bandwidth. Use rel=preload with crossorigin for fonts loaded via @font-face. Don't preload fonts used only below the fold or in modals.

How do variable fonts affect performance?

Variable fonts combine multiple weights and styles into a single file, reducing HTTP requests and total byte size. One variable font file at 80KB can replace four static files totaling 200KB. They also enable smooth weight animations. The tradeoff is slightly higher parsing cost, which is negligible on modern devices.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →