Graceful Degradation When Fonts Time Out
This guide is part of the Font Loading Error Handling & Resilience area, itself under Font Performance Monitoring & Auditing.
A font-display value of swap promises that text will never stay invisible for more than a few seconds, but it does not promise a good fallback experience. If your web font is served from a CDN that is having a bad day, or a corporate proxy strips your Cache-Control header and forces a re-fetch on every navigation, the browser will eventually give up on the custom font and fall back to whatever generic family you listed last in the font-family stack — usually serif or sans-serif, at whatever metrics the operating system happens to ship. That fallback is rarely metric-matched, so paragraphs reflow, headlines wrap onto a new line, and Cumulative Layout Shift spikes right at the moment a slow network was already hurting your Core Web Vitals. Graceful degradation means treating "the font didn't load in time" as an explicit, testable state with its own CSS, not an accident that happens to whatever the browser's default swap timeout produces.
The technique in this guide races your own deadline against the font load, independent of the browser's internal 3-second block period, and flips a class on the document root the instant that deadline passes. Everything downstream — headings, body copy, icon glyphs — reads that class and switches to a pre-tuned, metric-matched fallback stack instead of an untuned system default. The result is a page that degrades in a way you designed, not one the network dictated.
Prerequisites
- A self-hosted or CDN-delivered
@font-facedeclaration usingfont-display: swaporfont-display: optionalas the CSS-level safety net. - Familiarity with the CSS Font Loading API, specifically
document.fontsandFontFace.load(). - A fallback stack whose x-height and cap-height have already been tuned — see Calculating size-adjust for a system-ui Fallback if you have not built one yet. Without a metric-matched fallback, this pattern still stops FOIT, but it will not stop layout shift.
- A way to ship a timeout budget as a design decision, not a magic number: 2000–2500ms is a reasonable ceiling for LCP-critical text on a 4G connection profile.
Implementation
The core idea is a Promise.race() between the real font load and a setTimeout that resolves to a sentinel "timed out" value. Whichever settles first decides whether the document gets a fonts-loaded class or a fonts-failed class.
const FONT_TIMEOUT_MS = 2200;
const root = document.documentElement;
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve('timeout'), ms);
});
}
async function loadPrimaryFont() {
const inter = new FontFace(
'Inter',
'url("/fonts/inter-var.woff2") format("woff2-variations")',
{ weight: '100 900', display: 'swap' }
);
document.fonts.add(inter);
const result = await Promise.race([
inter.load().then(() => 'loaded'),
timeout(FONT_TIMEOUT_MS),
]);
if (result === 'loaded') {
root.classList.add('fonts-loaded');
root.classList.remove('fonts-failed');
} else {
root.classList.add('fonts-failed');
// Keep trying in the background in case the network recovers;
// a late arrival still upgrades the page without a reload.
inter.load().then(() => {
root.classList.remove('fonts-failed');
root.classList.add('fonts-loaded');
}).catch(() => {
// Leave fonts-failed in place; see the CSS fallback block below.
});
}
}
loadPrimaryFont();
:root {
--font-primary: 'Inter', system-ui, sans-serif;
}
body {
font-family: var(--font-primary);
}
/* Default state before JS resolves: browser's own font-display: swap
already prevents invisible text, so no extra rule is needed here. */
.fonts-failed body,
.fonts-failed {
--font-primary: 'Inter Fallback', system-ui, sans-serif;
}
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
Line by line: FONT_TIMEOUT_MS is your own budget, deliberately shorter than the browser's 3-second block period for swap so you get to decide the fallback before the browser's default kicks in unpredictably across engines. timeout() never rejects — it resolves — because Promise.race needs a settled value to compare against, and a rejected timeout would throw instead of losing the race cleanly. loadPrimaryFont() registers the FontFace with document.fonts.add() before calling .load(), which matters: without registering it first, a successful late load will not automatically apply to elements that already rendered with the fallback, because the browser never associates the loaded face with your stylesheet's @font-face rule. The fonts-failed branch does not give up — it keeps the original .load() promise alive and upgrades the page silently if the font arrives even a few hundred milliseconds late, which is common on flaky mobile networks where a request stalls but ultimately completes.
The CSS half swaps a custom property rather than an entire font-family list on every element, so nested font-family: var(--font-primary) declarations across your component library update from a single class toggle on <html>. The Inter Fallback face uses size-adjust, ascent-override, and descent-override to match Inter's vertical metrics against Arial — for details on deriving these three numbers from a font's own metrics tables, see Accessible Fallback Font Stacks & CLS Prevention.
A Defensive Variant: Per-Family Timeouts with Cleanup
A single global timeout works for a single-family page, but most production sites load two or three families — a display face for headings and a text face for body copy — each with its own risk profile. Headings are almost always above the fold and LCP-critical, so they deserve a tighter budget than body copy that scrolls into view later. The variant below tracks each family independently and clears its timer the moment the real load settles, which avoids a stray setTimeout firing after the font already resolved and momentarily flashing the fallback class back on.
function raceFontLoad(fontFace, { timeoutMs, failedClass, loadedClass }) {
return new Promise((resolve) => {
let settled = false;
const root = document.documentElement;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
root.classList.add(failedClass);
resolve({ family: fontFace.family, status: 'timeout' });
}, timeoutMs);
document.fonts.add(fontFace);
fontFace.load()
.then(() => {
if (settled) {
// Late arrival after timeout already fired: upgrade silently.
root.classList.remove(failedClass);
root.classList.add(loadedClass);
return;
}
settled = true;
clearTimeout(timer);
root.classList.add(loadedClass);
resolve({ family: fontFace.family, status: 'loaded' });
})
.catch((err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
root.classList.add(failedClass);
resolve({ family: fontFace.family, status: 'error', reason: err.name });
});
});
}
const heading = new FontFace('Recoleta', 'url("/fonts/recoleta.woff2")', { display: 'swap' });
const body = new FontFace('Inter', 'url("/fonts/inter-var.woff2")', {
weight: '100 900',
display: 'swap',
});
Promise.all([
raceFontLoad(heading, { timeoutMs: 1500, failedClass: 'heading-fallback', loadedClass: 'heading-loaded' }),
raceFontLoad(body, { timeoutMs: 2200, failedClass: 'body-fallback', loadedClass: 'body-loaded' }),
]).then((results) => {
const failed = results.filter((r) => r.status !== 'loaded');
if (failed.length && 'sendBeacon' in navigator) {
navigator.sendBeacon('/rum/font-degradation', JSON.stringify(failed));
}
});
The settled flag is the load-bearing detail here: without it, a font that times out and then loads 400ms later would run both the timeout branch and the success branch, leaving the document in a class-thrashing state where heading-fallback and heading-loaded are both present and whichever CSS rule happens to come later in the stylesheet wins arbitrarily. Reporting failures through navigator.sendBeacon gives you a field-truth count of how often real users hit the fallback path — pair this with the technique in Detecting Failed Font Loads with FontFaceSet to distinguish a genuine 404/CORS failure from a merely slow network.
Verification
Confirm the timeout path actually triggers, and that it triggers only when it should, with three checks:
- Chrome DevTools throttling. Open the Network panel, set throttling to "Slow 3G," and hard-reload. Watch the Elements panel for the
fonts-failedclass landing on<html>at approximately your configured timeout, and confirm the fallback stack's cap-height visually lines up with the web font — no headline reflow when the real font arrives late. - Blocked font request. In the Network panel, right-click the font request and choose "Block request URL," then reload. This simulates a permanent failure (a bad CDN deploy, a typo'd path) rather than a slow one. The
fonts-failedclass should apply and stay applied — the retry.load()call will reject, and your.catch()branch must not leave the page in a half-swapped state. - PerformanceObserver confirmation. Run this in the console to see the actual timeline of font resolution events relative to your timeout:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime.toFixed(0), entry.duration.toFixed(0));
}
}).observe({ type: 'resource', buffered: true });
Cross-reference the font resource's responseEnd timestamp against the moment fonts-failed was added; if the class appears before responseEnd, your timeout fired correctly ahead of a slow load, which is the intended behavior, not a bug.
Common Pitfalls
- Setting the JS timeout longer than the browser's own
font-display: swapblock period. The block period forswapis effectively 0ms in most engines — text renders with the fallback immediately and swaps in-place later — so a 3-second JS timeout does nothing to prevent invisible text; it only decides when to apply your tuned fallback class instead of the browser's untuned default. Keep the JS timeout short (1500–2500ms) so the tuned fallback appears quickly, not as an afterthought. - Forgetting
document.fonts.add()before.load(). Calling.load()on aFontFacethat was never added todocument.fontswill still resolve the promise, but the browser will not apply the face to matching CSS rules, so text keeps rendering in the fallback forever even though your code thinks the load "succeeded." - Not clearing the timer on success, which lets a stray
setTimeoutfire after the real font already loaded and briefly flips the fallback class back on — visible as a one-frame flash of reflowed text on fast connections, which is the opposite of what this pattern is for. - Shipping an untuned fallback stack. A timeout-driven class swap only fixes FOIT/indefinite invisibility; it does nothing for CLS unless the fallback's
size-adjust,ascent-override, anddescent-overrideare actually tuned against the web font's metrics. - Treating every timeout as a permanent failure. Mobile networks stall and resume constantly. If you disable the retry
.load()call after a timeout, you turn a 400ms hiccup into a fallback font for the rest of the session, even on a page that stays open for minutes. - Testing only on fast Wi-Fi. The entire failure mode this guide addresses is invisible on a desk connection. Always validate with DevTools throttling or an actual blocked request before shipping.
Measuring How Often Users Actually Hit the Fallback
A timeout you never observe in production is a guess, not a design decision. Once the sendBeacon reporting shown above is wired up, aggregate the status field server-side and watch it as a rate, not a raw count — a CDN region outage that drives 40% of a country's traffic into the fallback path for twenty minutes looks very different in an alerting dashboard than in a total-count metric that just creeps up slowly over a week of background packet loss. Bucket the reports by connection type (from navigator.connection.effectiveType where available) and by the specific font family, since a display face loaded from a separate origin than your body text will often fail independently of it — a expired TLS certificate on a fonts subdomain, for instance, fails every request to that origin while leaving a same-origin body font completely unaffected.
It is also worth tracking how late a font arrived when it did eventually resolve after a timeout. If the median late-arrival time is 300ms past your budget, the timeout is well-tuned — most users get the tuned fallback for only a brief window before the upgrade. If the median is 4 or 5 seconds past budget, that is a signal the underlying delivery problem (CDN cache misses, a misconfigured Cache-Control header, or an oversized variable font file) needs fixing at the source rather than papering over with a longer timeout. Comparing this data against the guidance in Measuring Font Loading Performance: A Timing Workflow will tell you whether the timeout pattern in this guide is compensating for a rare edge case or masking a systemic delivery regression that deserves its own fix.
Table: Which Signal Should Trigger the Fallback Class
Not every failure mode looks the same from JavaScript's point of view, and the fallback class should trigger for all of them, not just a clean timeout:
| Failure mode | What FontFace.load() does |
Detected by |
|---|---|---|
| Slow network, font arrives late | Promise stays pending past your deadline | Promise.race timeout branch |
| 404 / typo'd font URL | Promise rejects | .catch() handler |
| CORS misconfiguration on CDN | Promise rejects with a SecurityError |
.catch() handler, err.name |
| Font file fails to parse (corrupted) | Promise rejects | .catch() handler |
| Script blocked entirely (ad blocker, CSP) | JS never runs | font-display: swap CSS fallback only |
The last row is the reason font-display: swap in the @font-face rule can never be optional even when this JavaScript pattern is in place: if the script itself is blocked or errors before it runs, there is no JavaScript-driven fallback class at all, and the CSS-level font-display behavior is the only thing standing between the user and indefinitely invisible text.
Frequently Asked Questions
Does this replace font-display: swap in the CSS?
No — keep font-display: swap (or optional) on your @font-face rule as the browser-native safety net. This pattern adds a second, application-level layer on top: it decides which fallback stack appears and when, using your own timeout budget rather than the browser's internal, less predictable block/swap periods. If your JavaScript fails to run at all (a script error, a blocked CDN for your JS bundle), font-display: swap alone still guarantees text is visible.
What timeout value should I actually use? Start from your LCP budget. If your target LCP is 2.5s and the font request typically starts around 200–400ms in, a 1500–2000ms timeout for above-the-fold headings leaves enough runway for the real font to still win the race on a typical connection while guaranteeing the fallback appears well inside the "good" LCP threshold on a bad one. Body copy that renders below the fold can tolerate a slightly longer budget, around 2200–2800ms, since it isn't scored by LCP directly but still affects CLS.
Will the retry after a timeout cause a second layout shift when the font finally loads?
It causes a font swap, but not necessarily a layout shift, provided your fallback stack's metrics were tuned to match the web font. A well-tuned size-adjust/ascent-override/descent-override triple means line boxes, cap-height, and baseline stay put across the swap — see Accessible Fallback Font Stacks & CLS Prevention for how to derive those values from a font's OS/2 and hhea tables.
How does this interact with font-display: optional?
With optional, the browser itself decides very quickly (roughly one frame) whether to use the font at all for the current page load, and if it misses that window it won't swap in later on the same navigation. If you use optional, the timeout-to-fallback-class pattern in this guide becomes your primary mechanism for showing a tuned fallback rather than relying on optional's default system fallback — the trade-off, covered in font-display: swap vs optional — When to Use Each, is that you give up the possibility of the custom font ever appearing on that particular navigation once the deadline passes.