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
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-familystack to fallback-only and move every custom-family declaration behind a.font-loadedscope 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-facerule, carryingweight,style, andunicode-rangedescriptors 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, calldocument.fonts.add(face)and addfont-loadedtodocument.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()inPromise.raceagainst 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
sessionStorageflag. On subsequent navigations, read the flag in a blocking inline script and addfont-loadedbefore 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 theloadingdoneevent when late-loaded faces (icon sets, code-block fonts) need their own class toggles. Confirm ordering by logging both againstperformance.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
- Font Loading Optimization & FOUT Prevention — parent section: preload placement, subsetting, and fallback metric alignment
- Reducing Font Swap Duration with font-display: swap — the declarative counterpart: compressing the swap window itself
- Resource Hint Implementation & Preloading Strategies — up to the section root