FOUT vs FOIT Mitigation: Implementation Workflow
This guide is part of the Font Loading & Delivery Strategies blueprint, and it tackles the two failure modes every web font can produce while it loads: Flash of Unstyled Text (FOUT) and Flash of Invisible Text (FOIT). FOIT is the period where text takes up space but paints nothing; FOUT is the visible reflow when the web font swaps in over a fallback. Done right, the mitigation keeps Cumulative Layout Shift (CLS) below 0.1 and improves First Contentful Paint (FCP) by 15–30% by guaranteeing readable text at first paint and flattening the swap so it never moves the layout.
The Problem: Two Flashes, Two Different Costs
FOUT and FOIT are not two names for one bug — they sit at opposite ends of a perception/UX trade-off, and they hit two different Core Web Vitals. Precisely: FOIT is a block period during which the glyph slots are laid out but render nothing, so the reader stares at empty space until the web font arrives or the browser gives up (under auto/block, up to 3 seconds). FOIT therefore degrades LCP — if the largest text block is your LCP element, it cannot paint until the block period ends, pushing LCP past the 2.5s target on a slow link. FOUT is the opposite bargain: text paints immediately in a fallback, so LCP is fast, but when the real font swaps in, any difference in glyph advance, x-height, or line-box height reflows the line and registers a layout shift, degrading CLS against the 0.1 budget.
The choice between them is a choice about which metric you are willing to spend. Hiding text to avoid the reflow trades LCP for CLS; showing fallback text immediately trades CLS for LCP. The mitigation here refuses the trade entirely: use a descriptor that gives a 0ms block period (so LCP is never blocked) and a metric-matched fallback (so the swap is shift-free and CLS is never charged). You get the fast paint of FOUT with none of its layout cost.
Diagnosing FOUT vs FOIT
Before fixing anything, classify which flash you actually have, because the fixes differ. Open Chrome DevTools, enable Disable cache, throttle to Slow 4G, and reload while watching the viewport. If a block of text is blank for a noticeable beat and then characters appear, that is FOIT — an invisible block period. If text appears instantly in a system font and then the glyphs visibly change shape, width, or line breaks when the web font lands, that is FOUT. FOUT is the desired behavior; the problem with it is only the layout shift it causes, not the swap itself.
Pin the cause to a descriptor. In the Elements panel inspect the offending @font-face rule, or run document.fonts.forEach(f => console.log(f.family, f.display, f.status)) in the console. A face reporting display: "auto" (or no descriptor at all) is the usual FOIT culprit, because auto behaves like block and hides text for up to 3 seconds. For the FOUT reflow, open Rendering → Layout Shift Regions and reload (with throttling on so the swap is slow enough to watch) — the box that flashes around your text the instant the font swaps is the CLS you need to eliminate. Quantify it with a layout-shift PerformanceObserver: the entries logged at the swap timestamp are your exact CLS contribution from font loading.
Quantifying the font-swap CLS contribution
// Log every layout shift, flag the one that fires at the font swap
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // ignore user-triggered shifts
console.log('shift', entry.value.toFixed(4), 'at', entry.startTime.toFixed(0), 'ms');
}
}).observe({ type: 'layout-shift', buffered: true });
Correlate each startTime against the moment the WOFF2 request finishes in the Network panel. A shift whose timestamp matches the font finish is your swap-attributed CLS; the sum of those values is the number you must drive under 0.1 (and ideally under 0.05). If no shift fires at the swap, the fallback is already metric-matched and your remaining work is purely the FOIT side.
Baseline Configuration
The minimum correct setup has two parts: a descriptor that eliminates FOIT, and a fallback face that eliminates the FOUT reflow. The descriptor choice maps directly to the timeline values from font-display values explained — swap gives a 0ms block period and an infinite swap window, so text is readable immediately and the web font always arrives. That kills FOIT. It does not kill FOUT: the swap still happens, so you also need a metric-matched fallback so the swap moves nothing.
Baseline @font-face with swap to eliminate FOIT
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
Metric-matched fallback face to eliminate the FOUT reflow
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%; /* match glyph width/advance */
ascent-override: 90%; /* match line-box top */
descent-override: 22%; /* match line-box bottom */
line-gap-override: 0%;
}
/* Use both, fallback second: */
body { font-family: 'Inter', 'Inter Fallback', sans-serif; }
The override descriptors go on the fallback face, never the web font — they resize the system font to occupy exactly the space the web font will, so when the swap fires, no glyph moves. Deriving the exact percentages for your specific typeface pairing is covered in fallback font stack design.
The Strategy Spectrum
There is no single switch for these flashes; there is a spectrum of techniques, and a robust setup layers several. Pick the lightest one that meets your CLS and LCP budgets, then add the next only if a verification check still fails.
Descriptor-only (font-display). The cheapest fix. Set the swap window via font-display values explained: swap (0ms block, infinite swap) for body copy that must always appear; optional (100ms block, 0 swap) for decorative text where the browser may skip the web font on slow links and so guarantees zero swap shift; fallback (100ms block, 3s swap) as a middle ground that bounds how late a swap can fire. Choosing between the two common cases is detailed in when to use font-display swap vs optional. This alone removes FOIT but leaves the FOUT reflow.
Class-swap (the fonts-loaded pattern). Take the swap moment away from the browser's passive timeline. Render the fallback, load the font with the CSS Font Loading API, and only when its promise resolves add a fonts-loaded class on <html> that flips font-family to the web font. The full no-hydration-blocking implementation lives in eliminating FOUT with the CSS font loading API. The win is determinism: one repaint at a moment you control, not a swap the browser fires mid-layout.
Two-stage render with sessionStorage. The class-swap still shows a brief fallback on the first navigation. On repeat views within a session the font is already in the HTTP cache, so the fallback flash is pure waste. Cache the "fonts are ready" fact in sessionStorage, read it before first paint with an inline script, and apply fonts-loaded synchronously — the repeat view renders straight in the web font with no flash at all. The mechanics, including the inline-script ordering that avoids reintroducing FOIT, are in two-stage font render with session storage.
Metric-matched fallbacks (the shift killer). None of the above flattens the reflow on its own; only matching the fallback's box to the web font's does. size-adjust scales the fallback's glyph advance, while ascent-override, descent-override, and line-gap-override pin its line box, so the swap moves nothing. This is what lets you ship swap and keep CLS at zero. The derivation is in ascent-override / descent-override to reduce CLS.
In practice a production setup combines all four: swap so LCP is never blocked, a metric-matched fallback so the swap is shift-free, the class-swap pattern when you need a single coordinated repaint, and the sessionStorage two-stage trick to erase the flash on repeat views.
Step-by-Step Mitigation Workflow
Each step ends in a measurable verification so you never ship a half-mitigated setup.
Implementation Steps:
- Audit every
@font-facefor an explicit descriptor. Inspect computed styles in DevTools and confirm no face reportsauto. Verify:document.fonts.forEach(f => console.log(f.family, f.display))showsswap,fallback, oroptionalon every critical face — neverauto. - Apply
swapto primary body text. This sets the block period to 0ms so text is readable instantly. Verify: on a Slow 4G reload, the body text paints in the fallback at first paint with no blank period (no FOIT). - Apply
optionalto decorative/display fonts. These tolerate the web font never appearing on slow links, which guarantees zero swap-induced shift. Verify: on Slow 4G the decorative face keeps the fallback and produces no layout shift; on a fast/cached load it renders the web font. - Add a metric-matched fallback face for each swapped family. Derive
size-adjust,ascent-override,descent-override, andline-gap-override. Verify: with Layout Shift Regions on, the swap fires with no flashing region around the text. - Preload the above-the-fold weights. Emit
<link rel="preload" as="font" type="font/woff2" crossorigin>for the one or two weights first paint uses, per preloading & resource hints. Verify: DevTools Network shows those fonts at Highest priority with no "preloaded but not used" warning. - Subset the payload. Run
pyftsubsetso each file is under your budget, shortening the swap settle time. Verify: each output WOFF2 is under ~50KB and the swap completes earlier in the waterfall. - Measure CLS and FCP before and after. Run a
layout-shiftobserver plus Lighthouse. Verify: CLS stays under 0.1 and FCP improves on the throttled profile relative to the pre-mitigation baseline.
Browser Compatibility & Fallback Matrix
| Capability | Chrome/Edge | Firefox | Safari |
|---|---|---|---|
font-display descriptor |
60+ | 58+ | 11.1+ |
size-adjust on fallback face |
92+ | 92+ | 17+ |
ascent/descent/line-gap-override |
87+ | 89+ | 17+ |
CSS Font Loading API (document.fonts) |
35+ | 41+ | 10+ |
optional skips swap on slow net |
Yes | Yes | Yes |
rel="preload" for fonts |
50+ | 85+ | 11.1+ |
Two edge cases deserve attention. First, the metric-override descriptors only reached Safari in version 17, so on older Safari the FOUT reflow will not be flattened by size-adjust; there, either accept a small CLS contribution or bound the swap window with fallback. Second, Safari historically required an explicit type="font/woff2" on preload links to avoid double-fetching the file — always include the type attribute and confirm CORS headers match the crossorigin attribute, since a font fetch is always in CORS mode.
Code Configuration Examples
Optimized @font-face with swap and a Latin subset
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
Declares the variable weight range, sets swap to eliminate FOIT, and restricts the character set so the file is small enough to swap in quickly. Split additional scripts with unicode-range subset loading.
CSS Font Loading API promise chain to control the swap explicitly
document.fonts.load('1rem Inter').then(() => {
document.documentElement.classList.add('fonts-loaded');
}).catch(() => {
document.documentElement.classList.add('fonts-failed');
});
Taking the swap out of the browser's passive timeline lets you transition it under a .fonts-loaded class — the full pattern, including avoiding hydration blocking, is in eliminating FOUT with the CSS font loading API.
Two-stage render that kills repeat-view FOUT with sessionStorage
<!-- Inline in <head>, before any stylesheet, so it runs before first paint -->
<script>
if (sessionStorage.fontsLoaded) {
// Repeat view: font is cached, paint straight into it — no flash
document.documentElement.classList.add('fonts-loaded');
}
</script>
<script>
// First view: load, then remember for the rest of the session
document.fonts.load('1rem Inter').then(() => {
document.documentElement.classList.add('fonts-loaded');
sessionStorage.fontsLoaded = '1';
});
</script>
On the first navigation the reader sees one coordinated swap; on every later navigation in the session the inline check applies fonts-loaded synchronously before paint, so the page renders directly in the web font. The ordering matters — the synchronous read must run before the stylesheet so the class is present at first paint. The full edge-case handling is in two-stage font render with session storage.
Preload link for the critical weight
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
Pulls the font into the connection the browser already opened for the HTML, so it is in flight before the @font-face is even parsed. crossorigin is required even same-origin, or the file is fetched twice.
Subsetting command to shrink the swap payload
pyftsubset Inter.ttf \
--unicodes="U+0000-00FF,U+0131,U+0152-0153" \
--layout-features=kern,liga \
--flavor=woff2 \
--output-file=inter-latin.woff2
Common Pitfalls
- Using
font-display: blockto "hide the flash." It produces up to 3s of invisible text on slow networks — the worst FOIT — and most users read that as a broken page. Useswapfor body text instead. - Relying on
swapalone without metric-matched fallbacks.swapfixes FOIT but the web font still swaps in; withoutsize-adjust/ascent-overrideon the fallback you get a visible reflow and a CLS penalty. FOIT and FOUT need different fixes. - Omitting
crossoriginon preload links. The preloaded font ends up in a different CORS mode than the real fetch, so the browser discards it and downloads the font a second time, wasting bandwidth. - Leaving
unicode-rangeoff, so the swap waits on a megabyte-scale file. An unsubsetted multi-script family delays the swap settle and prolongs any block window; subset to the ranges you render. - Blocking all rendering on
document.fonts.ready. That promise resolves only after every declared font settles, so awaiting it before paint reintroduces FOIT. Use it for post-paint work, anddocument.fonts.load()for the specific critical family. - Forgetting Safari's metric-override gap. Shipping
size-adjust-based flattening and assuming it works everywhere leaves pre-17 Safari with the reflow; verify CLS in that engine or bound it withfallback.
Frequently Asked Questions
When should font-display: optional be prioritized over swap?
Use optional when layout stability outranks brand fidelity — decorative or display fonts, or any text where a late swap would be more disruptive than the system fallback. On slow connections the browser keeps the fallback permanently for that page view, so there is no swap and no FOUT shift at all. Use swap when the custom font must always eventually appear regardless of connection speed, such as body copy, and pair it with metric-matched fallbacks to neutralize the reflow.
How does the CSS Font Loading API impact Core Web Vitals?
It lets you control paint timing instead of leaving it to the passive @font-face timeline. document.fonts.load() gives you a promise that resolves exactly when a specific family is ready, so you can toggle a class and trigger a controlled transition that minimizes the perceived FOUT. Done correctly it reduces CLS by synchronizing the visual swap with a known font-ready moment rather than letting it fire mid-layout — but do not await document.fonts.ready before first paint, or you reintroduce FOIT.
What caching headers optimize repeat-visit font rendering?
Serve content-hashed font files with Cache-Control: public, max-age=31536000, immutable. The immutable directive tells the browser never to revalidate within the max-age, so on repeat visits the font reads straight from disk cache and bypasses the entire block/swap lifecycle — there is no FOIT or FOUT at all because the font is already present at first paint. The mechanics of how that cache behaves across sites are detailed in browser font caching mechanics.
Does the sessionStorage two-stage trick reintroduce a flash on the first page view?
No, and that is the point of splitting it into two stages. On the very first navigation there is no cached flag, so the page renders the metric-matched fallback and performs one coordinated swap when the font promise resolves — the normal, shift-free FOUT. The sessionStorage flag only changes subsequent views in the same session: the inline synchronous read applies the fonts-loaded class before first paint, so those views never show the fallback at all. The first view costs you one swap; every view after it costs nothing. If you also serve the font with a long immutable cache header, even a brand-new tab reads the bytes from disk, so the first-view swap is the only flash a returning user ever experiences.