Code of the Day
AdvancedWeb Performance & Delivery

Font Loading Strategies

Web fonts are render-blocking by default and cause invisible text — font-display and preloading turn a common performance liability into a controlled tradeoff.

Web FoundationsAdvanced6 min read
Recommended first
By the end of this lesson you will be able to:
  • Explain the FOIT and FOUT failure modes and why they occur
  • Choose the right font-display value for a given use case
  • Add a font preload link to reduce the time before text is readable
  • Evaluate when self-hosting beats a font CDN for LCP

Typography is often the first thing a designer cares about and the last thing a performance engineer thinks about. Web fonts sit at the intersection of both concerns: they are essential for brand identity, and they are one of the most reliable causes of invisible text on page load. Understanding the loading lifecycle gives you the tools to keep both camps happy.

What happens when a font loads

The browser discovers a @font-face rule inside a stylesheet. At that point it knows a custom font exists — but it does not fetch it yet. Font files are only requested when the browser finds an element in the DOM that uses the font family.

Once the request is dispatched, the browser has to decide what to show in the meantime. This decision produces two well-known failure modes:

  • FOIT (Flash of Invisible Text): the browser hides text entirely while the font loads, showing blank space where words should be. This is the default behaviour in most browsers for a window of up to three seconds.
  • FOUT (Flash of Unstyled Text): the browser shows text immediately in the fallback font, then swaps to the custom font when it arrives. The text jumps slightly when the swap happens, which contributes to .

Neither is perfect. FOIT makes the page feel broken — content is there but invisible. FOUT causes a visual jump. Your choice of font-display controls which behaviour you get.

The font-display descriptor

font-display is declared inside @font-face and has five values:

@font-face {
  font-family: 'Brand Sans';
  src: url('/fonts/brand-sans.woff2') format('woff2');
  font-display: swap;
}
ValueBlock periodSwap periodPractical effect
autoBrowser decidesBrowser decidesUnpredictable — avoid
blockUp to 3 sInfiniteFOIT for up to 3 s, then swaps
swap~0 msInfiniteFOUT immediately; always swaps
fallback~100 ms~3 sBrief FOIT; if font not loaded in 3 s, stays fallback
optional~100 msNoneUses font only if cached or arrives almost instantly

font-display: swap is the most common recommendation for body text and headings where the content must be readable immediately. The fallback font appears at once; the custom font swaps in when ready. The trade-off is a brief visual jump, but users see text.

font-display: optional is the right choice when FOUT is genuinely disruptive — for example, a code font where monospace metrics are critical. It only uses the custom font if it is already cached or loads within a very short window (roughly 100 ms). On repeat visits (where the font is cached) users get the custom font; on a first visit they see the fallback permanently and the custom font silently caches for next time.

font-display: optional is the only value that never causes CLS from a font swap — because the swap period is zero. For fonts that affect layout metrics significantly, it is worth the trade-off of sometimes showing the fallback font.

Preloading fonts

Even with font-display: swap, the fallback period lasts as long as it takes the browser to discover, request, download, and parse the font file. Preloading the font starts the download earlier — as early as the HTML itself is parsed:

<head>
  <link
    rel="preload"
    href="/fonts/brand-sans.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  >
</head>

The crossorigin attribute is required even for same-origin fonts — the font fetch uses CORS internally, and omitting crossorigin causes a double download.

Without preload, the timeline is:

  1. Browser parses HTML
  2. Browser downloads CSS
  3. Browser parses CSS, discovers @font-face
  4. Browser downloads font file
  5. Text renders

With preload, steps 1 and 4 happen simultaneously — the font downloads while CSS is still being fetched. This can shave 300–800 ms off the FOUT window, which directly improves when the hero element contains text.

Font subsetting

A full unicode font contains thousands of glyphs — Chinese, Arabic, mathematical symbols, emoji, and more. A Latin-only website needs perhaps 200–300 of them. Shipping the full font wastes 80–95% of the download.

Font subsetting removes unused glyph ranges, producing a much smaller file:

/* Request only the Latin character range from Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap&subset=latin');

For self-hosted fonts, tools like glyphhanger, fonttools, or the Font Squirrel generator produce subsetted WOFF2 files from any font. A Latin subset of Inter, for instance, shrinks from ~300 KB to ~30 KB.

System fonts: the zero-download option

The fastest font to load is the one already on the device. The system font stack requires no network round-trip at all:

body {
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    Roboto,
    Helvetica,
    Arial,
    sans-serif;
}

On macOS and iOS this renders San Francisco. On Windows it renders Segoe UI. On Android it renders Roboto. Each looks native on its platform. This is not a compromise — many high-quality products (GitHub, Linear, Notion) ship with system fonts for body text and reserve custom fonts only for brand moments.

Self-hosting versus a font CDN

Google Fonts is the default choice because it is zero-configuration. The cost is a DNS lookup, TCP connection, and TLS handshake to fonts.googleapis.com before the CSS arrives, and then a second round-trip to fonts.gstatic.com for the font file itself.

With <link rel="preconnect"> the connection overhead is eliminated:

<head>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>

Self-hosting eliminates the cross-origin round-trips entirely. The font lives on your own domain, served by your own CDN, and can be preloaded with zero extra connections. For the one or two fonts that affect your LCP element, self-hosting with preload is measurably faster — typically 100–400 ms better FOUT timing — than relying on a third-party CDN.

Self-hosting means you are responsible for keeping font files up to date. Google Fonts silently updates font hinting and character coverage occasionally; self-hosted files stay frozen at the version you downloaded. For most projects this is a minor concern, but it is worth knowing.

Where to go next

Fonts and images covered, the remaining performance levers live in your build output. Asset Optimization explains minification, bundling, code splitting, tree shaking, compression, and cache headers — what your build tools produce and why it matters.

Finished reading? Mark it complete to track your progress.

On this page