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 three font-display periods on a timeline A load timeline split into a block period showing invisible text, a swap period showing fallback then web font, and a failure period locking in the fallback. Font load lifecycle after request start Block period invisible text (FOIT) Swap period fallback, then swap (FOUT) Failure period fallback locked in t = 0 request start time font-display sets the length of the block and swap segments

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
The 3-second block cap punishes near-boundary arrivals Two arrival scenarios along a 3-second block cap: a font at 2.9 seconds wastes the whole window, a font at 3.1 seconds locks the fallback then swaps late. block period β€” invisible text (0 to ~3000ms) 3000ms cap 2900ms: arrives 2.9s of blank wasted 3100ms: arrives fallback locked, then late swap Either side of the cap is a loss β€” never use block above the fold

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.

Block and swap segment lengths per font-display value Four horizontal bars comparing block and swap period lengths for block, swap, fallback and optional values against a shared time axis. Period lengths by value (block = amber, swap = teal) block swap fallback optional no swap segment β€” never a late swap, zero CLS 0ms time to fallback commit

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.

Font Loading API sequence for a gated critical heading A sequence from constructing a FontFace, adding it to document.fonts, calling load, decoding glyphs, and resolving the promise to reveal styled text. Imperative fetch, independent of render-tree matching new FontFace() display: swap document.fonts.add registers face .load() fetch + decode promise resolves add .fonts-loaded class reveal styled heading, no re-swap runs before any element matches the font

Verification

Confirm there is no invisible-text window and that the swap policy behaves as configured:

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. Because the preload scanner never discovers fonts referenced only in CSS @font-face rules, an explicit <link rel="preload" as="font"> is the only way to start the request before the CSSOM resolves.

Frequently Asked Questions

What is the difference between FOUT and FOIT?

FOIT (Flash of Invisible Text) is when the browser hides text during the block period while it waits for a web font β€” the reader sees blank space where glyphs should be. FOUT (Flash of Unstyled Text) is when the browser paints a fallback font immediately and then swaps to the web font once it loads, causing a visible restyle. font-display: block produces FOIT; font-display: swap produces FOUT.

Which font-display value should I use for body text?

Use swap for body and heading text you always want rendered in the web font, so readers never face invisible text and Largest Contentful Paint is not delayed. Pair it with size-adjust and ascent-override metric overrides to neutralize the layout shift the swap would otherwise cause. Reserve optional for decorative fonts where a cached-only policy and zero CLS matter more than always showing the font.

Why does font-display optional never cause layout shift?

optional gives the font a short block period of roughly 100ms and a zero-length swap period. If the font is not decoded within that window, the browser commits to the fallback for the entire page view and will not swap even after the font finishes loading. With no post-paint swap, there is no font-driven reflow, so no layout-shift entry is recorded after first paint.

Does preloading a font change how font-display behaves?

No. Preloading starts the network request earlier but the block, swap, and failure periods are still governed entirely by the font-display descriptor. Preload simply increases the chance the font arrives before the block period ends, which under optional is the difference between using the web font and permanently keeping the fallback.

When should I use the Font Loading API instead of font-display?

Use the Font Loading API when you need imperative control the declarative descriptor cannot give: starting a fetch before any element matches the font, or gating a single critical element on document.fonts.load() so it renders correctly the first time while the rest of the page keeps swap. For most fonts, font-display alone is sufficient.