⚡ 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-display configuration.
  • 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"
>
Important: Only preload fonts that are visible on the initial viewport (above the fold). Preloading fonts used only in the footer or deep in long articles wastes bandwidth and can slow down more critical resources. Aim for a maximum of 2–3 preloaded font files.

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 fonttools then pyftsubset inter.ttf --unicodes="U+0000-00FF". Generates a subset TTF which can be converted to WOFF2 with woff2_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.

📅 Created: 25 January 2026 | ✍️ Author: Performance Team | 🔄 Next Review: July 2026