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, no layout-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.