You open a page and the text briefly vanishes, then appears — or it shows up in a system font and suddenly snaps to a different one, pushing every line below it down. Both are symptoms of the same thing: the web font arrived late. A font file isn't even discovered until the browser has gone through HTML → CSS → and laid out the element that uses it. Only then is the file fetched, and until it lands the browser has to decide what to paint in the meantime. This guide walks through why fonts load late, the difference between FOIT / FOUT / FOFT, what each font-display value does, the layout shift swap causes, and the recipe for killing the jank.
Why fonts load late
The browser learns about the font file very late. The discovery chain:
1. Download HTML → parse
2. Find <link rel="stylesheet"> → download CSS → parse
3. Find the @font-face rule in CSS (NOT downloaded yet)
4. Lay out an element that actually uses that font
5. ↑ only now does the font file get requested
6. Font finishes downloading → (re)paint the textStep 4 is the key. A @font-face rule in your CSS does not trigger a download by itself. The font is fetched only once text that uses it is about to be painted — it's lazy. That saves you from downloading unused weights, but it also means the font is likely still in flight at the exact moment text becomes visible. Think of it as one extra round-trip stacked on top of the critical rendering path covered in how-browsers-render-pages.
FOIT vs FOUT vs FOFT
How the browser treats text before the font arrives splits into three symptoms:
- FOIT (Flash of Invisible Text) — the text is not shown at all until the font lands. Blank space. On a fast network you never notice; on a slow one the words are gone for a while.
- FOUT (Flash of Unstyled Text) — show a fallback (system) font immediately, then swap to the web font when it arrives. Text is always readable, but you see a flicker as the font changes.
- FOFT (Flash of Faux Text) — load only the roman (regular) weight first and render with it, faking bold / italic with synthesized (faux) styling until the real files arrive, then swap. An advanced staged-loading strategy for large families.
Which one you get is almost entirely decided by font-display. The old browser default was FOIT (a 3-second invisible period); modern defaults shorten that but still flash invisible briefly.
font-display — the 5 values
The browser splits font loading into two windows:
- block period — during this window text is invisible (it still reserves space). If the font arrives within it, that font is used.
- swap period — during this window a fallback font is shown, then swapped for the web font once it arrives.
@font-face {
font-family: "Inter";
src: url(/fonts/inter.woff2) format("woff2");
font-display: swap; /* ← right here */
}What each value does:
- auto — the browser default. Usually behaves like block (short block + roughly infinite swap), i.e. a mild FOIT.
- block — block period ~3s + infinite swap. Invisible for up to 3s (FOIT). If the font is slow the text is gone for a while. Use only where the wrong font must never show, like a logo.
- swap — block period ~0 + infinite swap. Show the fallback instantly, then swap (FOUT). Readability first. The safe default for body text — but watch the reflow (CLS) at swap time.
- fallback — a tiny block (~100ms) + a short swap (~3s). Arrives within 100ms → web font; in between → fallback; later than 3s → it is not swapped in (just cached for the next page). A short FOIT plus a bounded FOUT.
- optional — a tiny block + no swap. The browser looks at network conditions and decides whether to use the font at all. On a slow connection it stays on the fallback for this page and downloads the font in the background to cache. Best for CLS — close to zero. The recommended value for body text.
value block swap resulting symptom
─────────────────────────────────────────────────────
auto browser browser usually a mild FOIT
block ~3s infinite long FOIT
swap ~0 infinite FOUT (always swaps)
fallback ~100ms ~3s short FOIT + bounded FOUT
optional ~100ms none FOIT then locked fallback (min CLS)Practical defaults: block or swap for brand headlines, swap or optional for body text. If CLS worries you, optional + fallback metric matching is the safest combination.
The swap trap — layout shift (CLS)
font-display: swap reads well but is a classic CLS cause. If the fallback font and the web font have different metrics (glyph width, x-height, line-height math), then the moment the font is swapped the space the text occupies changes → lines reflow → everything below shifts. That eats into the Cumulative Layout Shift score covered in how-core-web-vitals-work.
Before swap (Arial fallback):
┌────────────────────────────┐
│ The quick brown fox jumps │ ← 3 lines
│ over the lazy dog and then │
│ runs away quickly │
└────────────────────────────┘
After swap (web font, wider glyphs):
┌────────────────────────────┐
│ The quick brown fox jumps │ ← grows to 4 lines!
│ over the lazy dog and │
│ then runs away │
│ quickly │
└────────────────────────────┘
↑ everything below shifts down by a line = CLSFix 1 — fallback metric overrides
Force the fallback font's metrics to match the web font, so the space the text occupies is identical before and after the swap and the shift disappears. You add four descriptors to @font-face:
/* an "adjusted fallback" tuned to the web font's metrics */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* overall glyph scale */
ascent-override: 90%; /* height above baseline */
descent-override: 22%; /* height below baseline */
line-gap-override: 0%; /* extra line spacing */
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}- size-adjust — scales the whole fallback glyph set so its average width matches the web font.
- ascent-override / descent-override — force the height taken above / below the baseline so line height lines up.
- line-gap-override — matches the extra spacing between lines.
The values come from comparing the two fonts' metrics — tedious to do by hand. So tooling does it for you.
Fix 2 — let the framework do it (next/font)
Next.js's next/font automates this at build time. It self-hosts the font (removing the external request) and reads the font's real metrics to generate an adjusted fallback @font-face with the size-adjust / ascent-override values baked in. The result: even when a swap happens, CLS converges to zero. The yutils site itself self-hosts its fonts and uses this approach.
// next/font example — automatic fallback metric matching
import {Inter} from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// adjustFontFallback defaults to on → size-adjust fallback generated
});The optimization recipe
1. self-host vs Google Fonts
The Google Fonts <link> approach is convenient but makes you connect to two extra domains: fonts.googleapis.com (the CSS) and fonts.gstatic.com (the font files). The DNS + TCP + TLS round-trips land right on the critical path. Self-hosting the fonts serves them from your own origin, removing the connection overhead and giving you control over caching and preloading. If you must use an external source, warm the connection ahead of time with the preconnect hint covered in how-resource-hints-work.
2. Ship WOFF2 only
WOFF2 uses Brotli compression and is ~30% smaller than WOFF and ~50% smaller than TTF. Every modern browser supports it, so you rarely need legacy fallbacks (TTF/EOT). A single format("woff2") is enough.
3. Preload critical fonts
For fonts that are guaranteed to render above the fold, bring the discovery point forward. A preload hint in the <head> fetches them without waiting for CSS to parse:
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>as="font"+typeare required so the file is fetched at the correct priority.- crossorigin is mandatory — fonts are requested in CORS anonymous mode even from the same origin. Omit it and the preload is ignored and the font downloads twice.
- Don't overdo it — only the one or two weights truly visible on first paint. Preloading too much competes for bandwidth with more important resources (like the LCP image).
4. Subset with unicode-range
A single font file doesn't need every glyph. A Latin-only page has no reason to download Cyrillic, Greek, or CJK glyphs. Splitting glyph ranges with unicode-range lets the browser fetch a subset file only when characters in that range actually appear on the page:
@font-face {
font-family: "Inter";
src: url(/fonts/inter-latin.woff2) format("woff2");
unicode-range: U+0000-00FF; /* Basic Latin + Latin-1 only */
}
@font-face {
font-family: "Inter";
src: url(/fonts/inter-cyrillic.woff2) format("woff2");
unicode-range: U+0400-04FF; /* Cyrillic — fetched only if needed */
}For languages with thousands of glyphs (like CJK), subsetting (only the common syllables) or dynamic subsetting cuts the file size dramatically.
5. Variable fonts — one file, many weights
Traditionally you download a separate file per weight: Regular / Medium / Bold / Italic, and so on. A variable font packs axes like weight, width, and slant into one file, covering any font-weight: 100–900 from a single download. If you use three or more weights, the variable file is usually smaller in total bytes.
@font-face {
font-family: "Inter";
src: url(/fonts/inter-var.woff2) format("woff2-variations");
font-weight: 100 900; /* declared as a range */
font-display: swap;
}6. Don't be greedy with weights
Each weight and style is a separate download (unless it's a variable font). If the design only needs Regular + Bold, loading six weights just makes fonts slower and wastes data. Declare only the weights you actually use.
The CSS Font Loading API — control from JS
When declarative font-display isn't enough, you can drive loading directly from JavaScript.
// 1. Wait for all fonts to finish loading
document.fonts.ready.then(() => {
document.documentElement.classList.add("fonts-loaded");
// e.g. start a specific animation only after fonts are ready
});
// 2. Define a font in JS and load it explicitly
const inter = new FontFace(
"Inter",
"url(/fonts/inter-var.woff2)",
{weight: "100 900", display: "swap"}
);
document.fonts.add(inter);
// 3. Check that a specific font is ready, then act
document.fonts.load('1rem "Inter"').then(() => {
// e.g. draw canvas text in this font
});document.fonts.ready— a promise that resolves once all font loading completes. The "fonts have arrived" hook.new FontFace(...)— define a font from code without CSS. Useful for conditional loading.document.fonts.load(...)— triggers the load of a specific font/size combination and reports completion via a promise. Often used before drawing text to a canvas.
A common pattern — add a class to html after fonts.ready so CSS can apply "fallback styles before fonts load, real styles after" (controlled FOUT). For most cases, though, font-display + fallback metric matching is enough with no JS at all.
Common pitfalls
1. Missing crossorigin on preload
The most common mistake. A font preload without crossorigin is ignored due to a CORS-mode mismatch, and the real font is fetched separately → double download.
2. Turning on swap but ignoring fallback metrics
display: swap removes the FOIT but introduces fresh CLS. You aren't done until you also add the fallback metric overrides (or use next/font).
3. Over-preloading
Preloading every weight competes with the LCP image and critical CSS for bandwidth. Preload only the weights visible on first paint.
4. Missing preconnect for Google Fonts
Using external fonts without preconnect means the font-file domain connection doesn't start until after the CSS downloads. Warm the connection first.
5. Huge fonts shipped without subsetting
Downloading a thousands-of-glyphs font (like CJK) whole can be several MB. It's common to ship the whole thing even on a Latin-only page. Split it with a unicode-range subset.
References
- web.dev — Optimize web font loading — web.dev
- MDN —
font-display— developer.mozilla.org - MDN — CSS Font Loading API — developer.mozilla.org
- Next.js — Font Optimization — nextjs.org
Takeaways
- A font isn't discovered or downloaded until HTML → CSS → the element that uses it is laid out. That's why it's late.
- FOIT = text invisible until it arrives. FOUT = show fallback instantly then swap. FOFT = roman first, bold/italic faked then swapped.
font-display— block (long FOIT) / swap (FOUT) / fallback (short FOIT) / optional (minimal CLS) / auto (browser default).- The cost of swap is CLS — a reflow from differing fallback vs web-font metrics. Fix it with
size-adjust·ascent/descent/line-gap-overrideor next/font's automatic fallback. - Optimize — self-host + WOFF2 + preload critical fonts (crossorigin required) + unicode-range subsetting + variable fonts + restraint on weights.
- The CSS Font Loading API —
document.fonts.ready,new FontFace(...),document.fonts.load()for JS control. - Next.js
next/fontself-hosts + auto size-adjust fallback — the same approach the yutils site uses.