⚡ Web Font Loading Optimization
Fonts are often the heaviest assets on a page after images. Poor font loading causes FOIT (Flash of Invisible Text) or FOUT (Flash of Unstyled Text), leading to layout shifts (CLS) and poor user experience. This guide covers strategies to ensure text is visible immediately and fonts swap seamlessly.
1. Understanding Font Loading Failure Modes
Before optimising, it helps to understand what actually goes wrong with font loading. There are two main failure modes:
- FOIT (Flash of Invisible Text): The browser hides text while the custom font loads, showing a blank space where text should be. On slow connections this can last 3+ seconds, leaving the page appearing broken. This is the default behaviour in most browsers without explicit
font-displayconfiguration. - FOUT (Flash of Unstyled Text): The browser shows text immediately using the system fallback font, then swaps to the custom font when it loads. Users see a visual "flash" as the typography changes. With a well-calibrated fallback (see the Font Fallbacks guide), this swap is imperceptible.
For Psilobase — a text-heavy educational resource — FOIT is significantly worse than FOUT. Users must be able to read content immediately, even on slow mobile connections. Configuration should prioritise visible text over typographic perfection.
2. The font-display Descriptor
The font-display descriptor in @font-face directly controls the loading behaviour. It introduces two periods: a block period (text is invisible, font is still loading) and a swap period (text shows in fallback, will swap when font arrives).
Recommended for Body Text: swap
Zero block period — text is never invisible. Swap period is infinite — the custom font replaces the fallback whenever it arrives (even minutes later). Users who scroll slowly may see the swap happen as they read. Acceptable trade-off for educational content where readability is paramount.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
Full Comparison of font-display Values
| Value | Block Period | Swap Period | Best For |
|---|---|---|---|
auto |
Browser decides (~3s) | Browser decides | Nothing — avoid |
block |
~3 seconds | Infinite | Icon fonts only |
swap |
~0ms | Infinite | Body text, main content |
fallback |
~100ms | ~3 seconds | Headings, logo fonts |
optional |
~100ms | None | Non-critical decorative fonts |
3. Preloading Critical Fonts
Even with font-display: swap, there is a period between when the CSS is parsed (and the browser discovers the @font-face declaration) and when the font file has downloaded. A preload hint moves the font download to happen in parallel with the HTML parse — before the CSS is even processed.
<!-- Place in <head>, before the CSS link -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
>
<!-- Also preload the heading font if it is visible above the fold -->
<link
rel="preload"
href="/fonts/merriweather-700.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
>
The crossorigin Attribute
The crossorigin attribute is required for font preloads, even when the font is served from the same domain. Without it, the browser will make two requests for the same font — the preload request and the actual fetch from the CSS parser. Both requests must use the same CORS mode to hit the cache.
4. Font Subsetting
A complete Inter font file includes glyphs for hundreds of languages and special characters — most of which Psilobase will never display. Subsetting removes unused glyphs and can reduce a font file from 350KB to 30–60KB.
Unicode Range Subsetting in CSS
The unicode-range descriptor tells the browser which characters this font file covers. The browser only downloads the file if the page actually uses characters in that range:
/* Latin subset — covers English and most Western European languages */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range:
U+0000-00FF, /* Basic Latin + Latin-1 Supplement */
U+0131, /* ı — dotless i */
U+0152-0153, /* Œœ */
U+02BB-02BC, /* ʻʼ — modifier letters */
U+02C6, /* ˆ — modifier circumflex */
U+02DA, /* ˚ — ring above */
U+02DC, /* ˜ — small tilde */
U+2000-206F, /* General Punctuation */
U+2074, /* ⁴ — superscript 4 */
U+20AC, /* € — euro sign */
U+2122, /* ™ — trademark */
U+2191-2193, /* ↑↓ — arrows */
U+2212, /* − — minus sign */
U+FEFF, /* BOM */
U+FFFD; /* replacement character */
}
Tools for Subsetting Font Binaries
- pyftsubset (fonttools):
pip install fonttoolsthenpyftsubset inter.ttf --unicodes="U+0000-00FF". Generates a subset TTF which can be converted to WOFF2 withwoff2_compress. - Fontsquirrel Webfont Generator: Online tool with subsetting options — useful for one-off conversions.
- Google Fonts with
text=parameter: For very specific text (site name in logo), pass only those characters:https://fonts.googleapis.com/css2?family=Inter&text=Psilobase
5. Self-Hosting vs Google Fonts CDN
Since Chrome removed cross-site font caching in 2020 (each origin's font cache is isolated), the shared-cache advantage of Google Fonts no longer applies. Self-hosting is now consistently faster because:
- No additional DNS lookup for fonts.googleapis.com or fonts.gstatic.com.
- No TCP/TLS handshake to a third-party server.
- Full control over caching headers (set to 1 year immutable).
- Privacy-compliant — no third-party requests that trigger GDPR concerns in the EU.
- Works offline with a service worker cache.
Migration from Google Fonts to Self-Hosted
<!-- BEFORE: Loading from Google Fonts CDN -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<!-- AFTER: Self-hosted with preload + calibrated fallback -->
<link rel="preload" href="/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/inter-700.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
</style>
Nginx Caching Header Configuration
# nginx.conf — long-term caching for font files
location ~* \.(woff2|woff|ttf)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
expires 1y;
}
6. Variable Fonts
Variable fonts consolidate multiple weights and styles into a single file. For a site that uses Inter at 400, 500, and 700 weights, three separate WOFF2 files total ~90KB. A variable Inter font at ~90–100KB covers all 900 weights in a single file — the same size, with unlimited typographic flexibility.
/* Variable font — one file, all weights */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900; /* Covers the entire weight axis */
font-style: normal;
font-display: swap;
}
/* Use any weight value, not just standard 400/700 */
.article-byline { font-weight: 450; }
.pull-quote { font-weight: 600; }
.safety-callout { font-weight: 650; }
.species-heading { font-weight: 700; }
All modern browsers (Chrome 66+, Firefox 62+, Safari 11+) support variable fonts. Support is universal for Psilobase's target audience.
7. Measuring Font Loading Performance
After implementing font optimisations, measure the actual impact:
- Chrome DevTools → Performance panel: Record a page load and look for the "Font" resource in the waterfall. Verify it loads before First Contentful Paint (FCP).
- WebPageTest.org: Run a Slow 3G test from multiple geographic locations. The "Font" row in the waterfall shows exactly when fonts become available.
- PageSpeed Insights → CLS score: A well-tuned font fallback and preloaded fonts should bring CLS below 0.1 (Google's "Good" threshold).
- Core Web Vitals report in Google Search Console: Shows real-user CLS data for your pages over the past 28 days. Font-related CLS often accounts for 30–50% of overall CLS on text-heavy sites.