Preventing FOUT and FOIT with font-display
Invisible text during load (FOIT) or a jarring glyph swap mid-read (FOUT) both come from the same source: the font-display descriptor governs how long the browser hides text waiting for a web font and whether it ever swaps. This page covers the block/swap period model, the ~3s block timeout, choosing swap versus optional, and using the Font Loading API to control the timing in JavaScript. It builds on Font Loading and Text Rendering and is part of Browser Rendering Pipeline Fundamentals.
The Three Periods
Every web font load is governed by three time windows. During the block period the browser renders text in an invisible fallback — space is reserved but no glyphs appear (this is FOIT). During the swap period the browser shows a visible fallback and will swap to the web font the instant it arrives (this is FOUT). After the failure period the browser gives up and uses the fallback permanently. font-display sets the length of the block and swap periods.
/* Reproduction: default behavior is block — text is invisible up to ~3s */
@font-face {
font-family: 'Merriweather';
src: url('/fonts/merriweather.woff2') format('woff2');
/* no font-display → 'auto' → block period of ~3s on a slow connection */
}
On a slow network the heading set in Merriweather renders nothing at all until the font arrives or three seconds elapse — a blank gap exactly where the most important text should be. That blank window delays the largest text node and inflates Largest Contentful Paint.
The 3-Second Block Timeout
The block value (and auto, which most engines treat as block) imposes a block period that Chrome and Firefox cap at roughly 3 seconds. For that entire window, affected text is invisible. If the font arrives at 2.9s, the user stared at empty space for 2.9s for no benefit. If it arrives at 3.1s, the browser has already locked in the fallback, then swaps anyway — the worst of both behaviors. This timeout is why block should never apply to above-the-fold content.
[Render timeline — font-display: block, slow 3G]
0ms | FCP fires, but text nodes in Merriweather paint NOTHING
0–3000ms | block period — invisible text, layout space reserved
2900ms | woff2 arrives → text finally paints in web font
LCP | 2900ms (text node) — fails the 2.5s budget
Choosing swap vs optional
/* Fix A — swap: paint fallback at 0ms, swap when the font loads */
@font-face {
font-family: 'Merriweather';
src: url('/fonts/merriweather.woff2') format('woff2');
font-display: swap; /* block period 0ms → no invisible text, ever */
}
/* Fix B — optional: use the font only if it is near-instant (cached) */
@font-face {
font-family: 'Merriweather';
src: url('/fonts/merriweather.woff2') format('woff2');
font-display: optional; /* ~100ms block, 0ms swap → no late swap, no CLS */
}
swap guarantees the user can read immediately and always eventually sees the web font, at the cost of a visible swap and a layout shift unless fallback metrics are matched. optional gives the browser a ~100ms window to use the font and a zero-length swap period: if the font is not ready in time, the browser commits to the fallback for that page view and never swaps. That means optional cannot cause a font-driven layout shift after first paint, which is its main appeal.
| Value | Block | Swap | Late swap? | CLS risk |
|---|---|---|---|---|
block |
~3s | infinite | yes | high (and FOIT) |
swap |
0ms | infinite | yes | high without metric overrides |
fallback |
~100ms | ~3s | only within 3s | medium |
optional |
~100ms | 0ms | never | none |
Use swap for content fonts you always want shown, paired with the metric overrides in Reducing layout shift from web fonts. Use optional for decorative or secondary fonts where a cached-only policy is acceptable and zero CLS is the priority.
Forcing Load with the Font Loading API
font-display is declarative; the Font Loading API gives imperative control. You can kick off a font fetch before any element matches it, and gate a render on completion to avoid the swap entirely for a critical block.
// Start the fetch immediately, independent of render-tree matching
const merri = new FontFace(
'Merriweather',
'url(/fonts/merriweather.woff2) format("woff2")',
{ display: 'swap' }
)
document.fonts.add(merri)
// load() returns a promise resolving when glyphs are decoded and ready
merri.load().then(() => {
document.documentElement.classList.add('fonts-loaded') // reveal styled text
})
// Or wait on a specific font + size before painting a critical heading
document.fonts.load('700 2rem Merriweather').then(() => {
performance.mark('heading-font-ready')
})
Gating a single critical heading on document.fonts.load() lets you keep swap globally while ensuring the one element most sensitive to a mid-read swap is rendered correctly the first time. Avoid gating the whole page — that reintroduces FOIT manually.
Verification
Confirm there is no invisible-text window and that the swap policy behaves as configured:
- Network panel shows the font request as
font -
document.fonts.ready - Under
optional, nolayout-shift
document.fonts.ready.then(() => {
const fcp = performance.getEntriesByName('first-contentful-paint')[0]
const ready = performance.getEntriesByName('heading-font-ready')[0]
if (ready && fcp && ready.startTime - fcp.startTime > 100) {
console.warn('Font readiness lags FCP — consider preload')
}
})
If the font readiness mark trails FCP by more than ~100ms, the fetch is starting too late; add a preload so the request begins during HTML parse rather than after render-tree matching.