Eliminating FOUT with CSS Font Loading API

The browser's default font pipeline shows fallback text until font files download. When network latency is high and no preload hint is set, users see the fallback font render and then abruptly swap to the web font — this is Flash of Unstyled Text (FOUT). Diagnose the issue using Chrome DevTools Performance tab by recording a page load and inspecting the font network waterfall. Cross-reference Layout Shift markers in the main thread track with the Lighthouse "Avoid large layout shifts" audit for quantifiable CLS impact.

Fix this by replacing passive @font-face declarations with document.fonts.load() promises and class-based CSS state transitions. Before implementing JS-driven control, review FOUT vs FOIT Mitigation to understand baseline rendering trade-offs. Align your typography pipeline with enterprise Font Loading & Delivery Strategies to synchronise CDN caching with programmatic font swaps.

CSS Font Loading API class-swap sequence A sequence showing document.fonts.load resolving to add a fonts-loaded class, or rejecting and timing out to add a fonts-fallback class that pins the system font stack. document.fonts .load('Inter') Promise.race 3s timeout resolve reject / timeout add .fonts-loaded swap to web font add .fonts-fallback pin system stack
The class-swap sequence: load() races a timeout, then toggles fonts-loaded or fonts-fallback so text is never left invisible.

Problem Statement

The default @font-face pipeline is passive: the browser decides when to swap the web font in, and with font-display: swap that swap fires whenever the file arrives — even seconds after the fallback has painted, producing a visible jump (FOUT) and, if the fallback metrics differ, a layout shift. The narrow failure this page resolves is the loss of control over when and whether that swap happens. By driving the swap from JavaScript with the CSS Font Loading API, you can hold the page on a metric-matched fallback, swap atomically via a single class toggle, and cap the wait with a timeout so a slow CDN never leaves text mid-transition.

Prerequisites

Before wiring up the API, confirm these are in place:

  • WOFF2 assets are served with Content-Type: font/woff2 and Cache-Control: public, max-age=31536000, immutable, and (if cross-origin) the correct Access-Control-Allow-Origin header, since the API respects CORS.
  • The target browsers expose document.fonts and the FontFace constructor — all evergreen engines (Chrome 35+, Firefox 41+, Safari 10+, Edge 79+) do.
  • Your @font-face declares font-display: swap (or optional) as the no-JS baseline, so text is never invisible if the script fails to run.
  • A metric-matched fallback @font-face exists (size-adjust, ascent-override, descent-override, line-gap-override) so the pre-swap state holds the same line box as the web font.
  • The font-family string you will pass to document.fonts.load() matches the @font-face font-family exactly, including casing — a mismatch silently never resolves.

API Initialization & Promise Resolution

Attach document.fonts.load('1rem Inter') to DOMContentLoaded or requestIdleCallback to avoid main-thread contention, then chain .then() to toggle a fonts-loaded class on the <html> element for an instant, atomic CSS state transition. The family string passed to the API must match the @font-face font-family declaration exactly — mismatched casing breaks resolution and leaves the fallback active forever. For multi-weight or variable stacks, batch the loads with Promise.all() to reduce waterfall fragmentation and ensure every weight swaps in the same frame.

Implementation: The Core Loading Promise

The primary pattern is a single load() call that resolves only when the font is parsed and ready for layout, at which point one class toggle swaps the whole page.

Core font-loading promise that swaps via a class toggle

document.fonts.load('400 1rem Inter').then(() => {
  document.documentElement.classList.add('fonts-loaded');
}).catch(() => {
  console.warn('Font load failed, fallback active');
});

The critical lines:

  • document.fonts.load('400 1rem Inter') parses a CSS font shorthand: it requests the 400 weight of Inter at 1rem. The weight and family must correspond to a declared @font-face, or the promise resolves with an empty array and no font loads.
  • The call both triggers the network request and returns a promise that resolves only when the file is downloaded and parsed — so the .then() runs at the exact moment the font is safe to render without a reflow mid-paint.
  • classList.add('fonts-loaded') on documentElement (<html>) flips one class; the swap is then expressed entirely in CSS, so the browser repaints text in a single atomic step rather than per-element.
  • .catch() is mandatory: a 404, CORS failure, or parse error rejects the promise, and without a catch the page would sit on the fallback with no signal. Here it logs, but the CSS baseline font-display: swap already guarantees text stays visible.

CSS State Management & Fallback Routing

Define .fonts-loaded body { font-family: 'Inter', sans-serif; } to trigger the swap once the promise resolves, and keep font-display: swap (or optional) on the @font-face so text is visible even before the script runs. Apply font-synthesis: none to block the browser's faux-bold/italic generation during load, which otherwise shifts layout, and pair metric-matched fallback descriptors (size-adjust, ascent-override, descent-override) with your fallback to minimise CLS when the class swap fires.

Metric-matched fallback and class-swap CSS

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF;
}

/* Metric-matched fallback minimises CLS during swap */
@font-face {
  font-family: 'InterFallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 23%;
  line-gap-override: 0%;
}

body { font-family: 'Inter', 'InterFallback', sans-serif; }
.fonts-fallback body { font-family: system-ui, -apple-system, sans-serif; }

The InterFallback face hosts the metric overrides over a zero-network local('Arial'), so the pre-swap state shares Inter's line box and the swap shifts no pixels. The .fonts-fallback rule is the explicit failure state the timeout variant below routes to.

Timeout & Error-Handling Variant

A bare load() promise can hang indefinitely on a high-latency CDN, leaving the page mid-transition. Wrap it in Promise.race() against a strict timeout so the render budget is bounded and a miss routes to a deterministic fallback class rather than an undefined state.

Race the load against a 3s timeout and route to a fallback class

const timeout = new Promise((_, reject) => setTimeout(reject, 3000));
Promise.race([document.fonts.load('1rem Inter'), timeout])
  .then(() => document.documentElement.classList.add('fonts-loaded'))
  .catch(() => document.documentElement.classList.add('fonts-fallback'));

The timeout promise rejects after 3000ms regardless of network state; Promise.race() settles on whichever finishes first. If the font wins, .then() adds fonts-loaded and the page swaps to Inter. If the timeout wins (or the load itself rejects on a 404/CORS error), .catch() adds fonts-fallback, which pins the system stack via the .fonts-fallback body rule — text is never left invisible and never swaps late on a slow 3G connection. Log the timeout branch with performance.mark() for Real User Monitoring so you can correlate font-load failures with CDN error rates, and clear any font-state flags stored in sessionStorage/localStorage on asset version bumps so a stale flag never blocks a freshly deployed file.

Browser Caching & Preload Integration

Pair the API calls with <link rel="preload" as="font" type="font/woff2" crossorigin> in the <head> to initiate the fetch before CSSOM construction — the crossorigin attribute is required even same-origin, or the preload double-fetches. Set Cache-Control: public, max-age=31536000, immutable to eliminate validation round-trips on repeat visits, and use unicode-range to subset Latin/Cyrillic glyphs for a faster initial parse and a smaller payload.

Verification

Confirm the programmatic swap behaves under throttling, not just on a warm cache.

  1. Open DevTools → Network → filter Font, tick Disable cache, throttle to Slow 3G, and reload. Watch the page paint in the metric-matched fallback, then swap to Inter only when the font row completes.
  2. In the Elements panel, observe the <html> element gain the fonts-loaded class at the swap moment — or fonts-fallback if you let the request stall past 3s (use Network → block the font URL to force this path).
  3. In the Console, run document.fonts.check('1rem Inter') — it returns false before the load resolves and true after, confirming readiness without forcing a reflow.
  4. Record a Performance trace across the load and confirm the Layout Shift events after the swap sum to CLS < 0.1; if not, recalibrate the fallback metric overrides.
  5. In Lighthouse, confirm no "Ensure text remains visible during webfont load" flag and that CLS stays under the 0.1 threshold in CI.

Common Pitfalls

  • Omitting font-display on the @font-face. Root cause: without a value the family resolves to auto/block, so if the script fails to run text is invisible (FOIT) instead of falling back. Fix: keep font-display: swap as the no-JS baseline on every @font-face.
  • Mismatched font-family strings between JS and CSS. Root cause: document.fonts.load('1rem inter') will not resolve a face declared as 'Inter' — the match is case-sensitive — so the fallback stays active forever. Fix: copy the exact family string from the @font-face block.
  • Blocking the main thread on document.fonts.ready. Root cause: awaiting the readiness promise synchronously in the critical path or before framework hydration stalls rendering and delays LCP. Fix: drive the swap off load().then() off the critical path; never gate hydration on font promises.
  • Ignoring crossorigin on preload links. Root cause: a preload without crossorigin fetches a font in a different CORS mode than the @font-face request, so the response is not reused and the file downloads twice. Fix: add crossorigin to the preload even same-origin.
  • Leaving stale font-state flags in storage. Root cause: a fonts-loaded flag cached in sessionStorage survives an asset version bump and skips loading the new file. Fix: key the flag on the asset hash/version and clear it on deploy.

FAQ

Does the CSS Font Loading API work in all modern browsers? document.fonts and the FontFace constructor are supported in all evergreen browsers (Chrome 35+, Firefox 41+, Safari 10+, Edge 79+). IE11 is end-of-life; do not maintain polyfills for it in new projects.

Should I use font-display: swap or optional with the API? Use swap for body text to guarantee readability during load — the API class toggle then provides a smooth visual transition. Use optional for decorative or display fonts to eliminate layout shifts entirely.

How do I debug failed font loads in DevTools? Filter the Network tab by Font to identify 404s or CORS errors. Inspect the Console for DOMException on document.fonts.load(). Verify @font-face src paths and ensure crossorigin attributes match server CORS headers.

Do I still need @font-face and font-display if I drive loading from JavaScript? Yes. The FontFace family must be declared (or constructed) for the API to resolve, and the font-display value on the @font-face is your no-JS safety net: if the script is deferred, blocked by an ad blocker, or errors before it runs, swap still keeps text visible. Treat the API as the enhancement layer on top of a correct CSS baseline, not a replacement for it.

Will Promise.all() across several weights cause a single combined swap? It will, provided you toggle the fonts-loaded class only after the combined promise resolves. Each weight downloads in parallel, and the class flip — applied once in the .then() — repaints every element in the same frame, avoiding the staggered per-weight shifts you would get if you toggled a class per individual load().

Related