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

Timeline comparing the default block policy, where text stays invisible until 2600 milliseconds, with the API-controlled flow, where fallback text paints at 80 milliseconds and a class swap applies the custom font at 900 milliseconds Two horizontal timelines run over 3200 milliseconds. In the default lane the block period paints text invisibly from 0 to 2600 milliseconds and only then shows the custom font. In the API-controlled lane a programmatic font fetch runs from 0 to 900 milliseconds against the preload-warmed cache while fallback text is already readable at 80 milliseconds; the font-loaded class then applies the custom font, and a 2 second race cap marks the point past which the fallback is kept instead. Who holds the swap trigger decides when text becomes readable Same 52 KB variable WOFF2, same Slow 4G profile (1.6 Mbps, 150 ms RTT) — only the trigger differs Default policy auto ≈ block block period — text painted invisible (FOIT) custom font first readable text ≈ 2600 ms API-controlled class swap on load FontFace.load() resolves at 900 ms from the warm cache fallback visible .font-loaded applied — custom font first readable text ≈ 80 ms 2 s race cap — if load() loses, the fallback stays 0 1000 ms 2000 ms 3000 ms invisible text (FOIT) 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>

The two snippets differ in one structural detail — the fixed version never lets a rendered element name Brand until the face object reports loaded — and that detail is what turns the font’s status machine into something the page can survive at every step.

State machine of the FontFace status transitions in the fixed snippet, showing unloaded, loading and loaded states plus the rejected branch taken when the 2 second race cap fires Three states run left to right: unloaded, where the face is constructed in JavaScript only; loading, once brand.load() is called and the fetch hits the warm cache; and loaded, about 900 milliseconds later, where fonts.add() runs and the class swap happens. Beneath each state sits its consequence: the fallback is readable at 80 milliseconds, the rejected branch fires when the 2 second cap wins and returns the page to the fallback, and a successful load records a sessionStorage flag so repeat views skip the race. FontFace.status transitions under the 2 s race No rendered element names the custom family until the face reaches loaded, so no state can paint invisible text unloaded constructed in JS only loading fetch hits the warm cache loaded fonts.add() + class swap brand.load() bytes decoded ≈ 900 ms page paints load() loses flag written fallback visible readable at 80 ms rejected 2 s cap fires first repeat view no race, no swap fallback stays Every exit is a readable one: the fallback stack is the base font-family, and .font-loaded only ever adds to it.

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.

Step 7 is the one teams get wrong most often, because the three gating jobs on a typical page each want a different signal:

Decision tree matching each gating job to its settled signal: the body copy uses the class swap behind a 2 second race, a one-time action uses document.fonts.ready, and a late-loaded face uses the loadingdone event A single question node on the left branches to three conditions. Body copy set in the custom family is gated with the class swap and the 2 second race cap. A one-time action such as an analytics mark or hero animation is gated on the one-shot fonts.ready promise. A late-loaded face such as an icon set or code-block font is gated on the loadingdone event and its fontfaces list. A note records that repeat views skip the tree entirely because the sessionStorage flag lets an inline script add the class before first paint. Which settled signal to gate on — three jobs, three answers Only the first branch needs the race cap; the other two are notifications, not render policy Which signal? what are you gating? body copy set in the custom family a one-time action — analytics mark, hero anim a late-loaded face — icon set, code-block font gate with gate on gate on class swap load() + 2 s race cap fonts.ready one-shot promise loadingdone event.fontfaces Repeat views skip the tree: with the sessionStorage flag set, an inline script adds .font-loaded before first paint.

Before/After Metrics

Measured on a throttled Slow 4G profile (1.6 Mbps down, 150 ms RTT) against a page using a 52 KB subset 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; the same mismatch is what produces the unused-preload console warning, so treat that message as the cheapest detector for this bug.


Related