Preventing FOIT with the CSS Font Loading API

Text on your page is invisible for up to three seconds on slow connections because web fonts load under the browser’s default block policy, and no CSS descriptor lets your application decide when the custom typeface is applied — the CSS Font Loading API does.

Root Cause: Declarative Font Policy Has No “When”

Left unconfigured, @font-face resolves font-display: auto, which every major engine treats as approximately block: for up to ~3 seconds after the font is first needed, text set in that family renders as invisible glyphs — laid out at fallback metrics but painted with transparent ink. On a fast connection the window closes in tens of milliseconds and nobody notices. On a congested mobile link the user stares at a page full of blank paragraphs, and the invisible interval bleeds directly into perceived First Contentful Paint even though the paint technically happened.

The deeper problem is architectural, not a bad default. Every CSS-side remedy — swap, fallback, optional, covered in the swap-window deep-dive — is a declarative render policy: you pick a timeout profile per font face and the browser executes it on its own schedule. There is no event, no promise, no way for the application to say “show the fallback now, and re-style only when I confirm the font is in memory.” The moment your stylesheet names the custom family on body, you have delegated the invisible-text decision to the engine.

The CSS Font Loading spec inverts that delegation. document.fonts exposes the document’s FontFaceSet — a live, observable collection of FontFace objects with per-face load promises, set-level events, and a ready promise. With it, the page can render fallback text unconditionally from the first paint, drive the font fetch programmatically (ideally against a cache already warmed by a preload hint), and opt into the custom typeface by toggling a class only after load() resolves. FOIT becomes structurally impossible: no element references an unloaded font, so the engine never has a reason to paint invisible text — and the application, not the browser, owns the timeout.

State Timeline: Default Block vs API-Controlled Swap

FOIT state timeline: default block vs Font Loading API Two horizontal timelines over 3.2 seconds. In the default flow, text is invisible from 0 to 2.6 seconds while the font downloads, then paints in the custom font. In the API-controlled flow, fallback text is visible from roughly 80 milliseconds, FontFace.load runs against a preload-warmed cache and resolves at 0.9 seconds, the font-loaded class applies the custom font, and a 2-second promise race caps the wait. Default (font-display: auto ≈ block) block period — text painted invisible (FOIT) custom font first readable text ≈ 2600 ms API-controlled (fallback first, class swap on load) FontFace.load() — preload-warmed cache fallback visible custom font — .font-loaded class applied first readable text ≈ 80 ms 2 s race cap — keep fallback if load loses 0 1000 ms 2000 ms 3000 ms Invisible text Fallback visible Custom font applied Programmatic font fetch

The two timelines paint the same font from the same cache, yet the reader’s experience diverges by two and a half seconds of readable text. The only structural change is who holds the swap trigger.

Minimal Reproduction

The broken version names the custom family directly, handing the block decision to the engine. The fixed version renders fallback-first and opts in programmatically.

<!-- BROKEN: body references the font immediately; the engine blocks
     text paint for up to ~3 s while brand-var.woff2 downloads. -->
<style>
  @font-face {
    font-family: 'Brand';
    src: url('/fonts/brand-var.woff2') format('woff2');
  }
  body { font-family: 'Brand', sans-serif; }
</style>
<!-- FIXED: fallback paints instantly; the custom family is gated
     behind .font-loaded, which JS applies only after load() resolves. -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/brand-var.woff2" crossorigin="anonymous">
<style>
  body { font-family: system-ui, sans-serif; }        /* visible from first paint */
  .font-loaded body { font-family: 'Brand', system-ui, sans-serif; }
</style>
<script>
  // Construct the face in JS so no CSS rule references it before load;
  // the URL must byte-match the preload href to reuse the warm cache entry.
  const brand = new FontFace('Brand',
    "url('/fonts/brand-var.woff2') format('woff2')",
    { weight: '100 900' });

  // Race the load against a 2 s cap: on slow links we deliberately
  // keep the fallback rather than reflow the page late in the session.
  Promise.race([
    brand.load(),
    new Promise((resolve, reject) => setTimeout(reject, 2000))
  ]).then(() => {
    document.fonts.add(brand);                 // register with the FontFaceSet
    document.documentElement.classList.add('font-loaded');
    sessionStorage.setItem('fonts', '1');      // repeat views skip the race
  }).catch(() => { /* fallback stays — no invisible text, no late reflow */ });
</script>

Deterministic Fix Protocol

  • [ ] 1. Ungate first paint from the font. Change the base font-family stack to fallback-only and move every custom-family declaration behind a .font-loaded scope on the root element. Verify with DevTools request blocking: block the WOFF2 URL, reload, and confirm text is readable immediately.
  • [ ] 2. Construct the face with the FontFace constructor. Create new FontFace(family, source, descriptors) in a small inline script instead of (or alongside) the @font-face rule, carrying weight, style, and unicode-range descriptors so the face matches your CSS usage exactly. A descriptor mismatch silently creates a second face that never matches any rule.
  • [ ] 3. Warm the cache with a preload, then call load(). Keep the <link rel="preload" as="font" crossorigin="anonymous"> in <head> so the fetch starts at parser time; brand.load() then resolves from the HTTP cache instead of opening a cold fetch. Verify in the Network panel that the font URL appears exactly once.
  • [ ] 4. Register and swap on resolution. In the load() handler, call document.fonts.add(face) and add font-loaded to document.documentElement.classList. The class flip triggers one style recalculation; batch it with any other post-load DOM writes to avoid a second layout pass.
  • [ ] 5. Cap the wait with a promise race. Wrap load() in Promise.race against a rejection timer (1.5–2.5 s depending on your p75 connection profile). On rejection, do nothing — the fallback stays for the session. This converts unbounded FOIT risk into a bounded, intentional FOUT budget.
  • [ ] 6. Skip the dance on repeat views. After a successful swap, set a sessionStorage flag. On subsequent navigations, read the flag in a blocking inline script and add font-loaded before first paint — the font is in the disk cache, so applying it immediately produces neither invisible text nor a visible swap.
  • [ ] 7. Choose your settled signal deliberately. Gate a single one-time action (analytics mark, hero animation) on document.fonts.ready; use the loadingdone event when late-loaded faces (icon sets, code-block fonts) need their own class toggles. Confirm ordering by logging both against performance.now() in staging.
  • [ ] 8. Verify the invisible interval is gone. Record a Performance trace on a throttled Slow 4G profile and inspect the filmstrip: text must be legible in the first painted frame, and exactly one text restyle may appear when the class lands.

Before/After Metrics

Measured on a throttled Slow 4G profile (1.6 Mbps down, 150 ms RTT) against a page using a 52 KB variable WOFF2, comparing the default block behaviour with the full protocol above.

Metric Default block API class swap Change
Invisible-text duration (cold cache) 2600 ms 0 ms eliminated
First readable text 2600 ms 80 ms −97%
Worst-case wait for custom font unbounded (~3000 ms) 2000 ms cap bounded
Restyles after first paint 1 (invisible → font) 1 (fallback → font) visible throughout
Repeat-view swap flashes 1 0 (sessionStorage flag) −1
Custom-font display rate at p75 71% 94% +23 pts

The display-rate gain is the counterintuitive win: because the race cap prevents pathological waits, the font can be applied confidently on mid-tier connections where a pure optional policy would have abandoned it.

FAQ

What is the difference between document.fonts.ready and the loadingdone event?

fonts.ready is a promise that resolves when the FontFaceSet reaches an idle state — every face currently marked for loading has settled (loaded or errored). It is a one-shot “everything so far is done” signal, ideal for gating a single action like an animation start. loadingdone is an event that fires after each batch of loads completes and can fire repeatedly as new faces are requested during the page’s lifetime; its fontfaces property lists exactly which faces landed, which is what you want for per-font class toggles on late-loaded typefaces.

Does the Font Loading API replace the font-display descriptor?

No — they operate at different layers and combine well. font-display is the browser’s declarative render policy per face; the API is an imperative load signal for your application. Under the class-swap pattern, font-display rarely gets a chance to matter, because no rendered element references the custom family until the face is already in memory. Still declare font-display: swap on any parallel @font-face rule as a safety net for elements that use the family without the gating class.

Why does FontFace.load() start a network fetch when I already preloaded the font?

It should not — a second fetch means the preload and the API load resolved to different cache entries. The usual causes are a URL mismatch (differing query string, a CDN redirect, or a relative-vs-absolute discrepancy between the preload href and the FontFace source string) or a missing crossorigin attribute on the preload link. Font fetches always run in CORS anonymous mode, so a preload without crossorigin="anonymous" warms a no-CORS cache slot that load() can never match. Byte-compare the two URLs in the Network panel and fix whichever attribute differs.


Related