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.
- 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 CLS.
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;
}| Value | Block period | Swap period | Practical effect |
|---|---|---|---|
auto | Browser decides | Browser decides | Unpredictable — avoid |
block | Up to 3 s | Infinite | FOIT for up to 3 s, then swaps |
swap | ~0 ms | Infinite | FOUT immediately; always swaps |
fallback | ~100 ms | ~3 s | Brief FOIT; if font not loaded in 3 s, stays fallback |
optional | ~100 ms | None | Uses 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:
- Browser parses HTML
- Browser downloads CSS
- Browser parses CSS, discovers
@font-face - Browser downloads font file
- 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 LCP 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.
Image Optimization
Images are typically the largest assets on a page — choosing the right format, size, and loading strategy can halve load time without changing a pixel of visible content.
Asset Optimization
Minification, bundling, caching, and compression reduce the bytes a browser downloads — most of this is handled by build tools, but understanding what they do lets you configure them deliberately.