A Timeout Pattern for FontFace.load()
This guide is part of the CSS Font Loading API: Implementation Guide topic, itself under the Font Loading & Delivery Strategies for the Web area.
FontFace.load() returns a promise that resolves once the font file has downloaded and been parsed, but the spec sets no ceiling on how long that can take. If the CDN serving your WOFF2 is slow, blocked by an ad blocker's request queue, or the user is on a throttled connection, the promise can sit unresolved for many seconds — or, on some cellular networks with stalled TCP connections, effectively forever. Any code that gates a class toggle or a render decision on that promise inherits the same unbounded wait. This is a distinct failure mode from font-display, which is a CSS-level timing contract enforced by the rendering engine; here we're talking about a JavaScript promise that your own code awaits, and JavaScript gives you no built-in deadline for it. If you're building a two-stage font render — paint with a fallback first, then swap to the web font once it's confirmed ready — that swap step needs a hard deadline, or a single bad network request can leave your page in an ambiguous "still waiting" state indefinitely.
The fix is a defensive wrapper: race the FontFace.load() promise against a setTimeout-backed promise, and treat the timeout winning as a signal to fall back deliberately rather than continuing to wait.
Prerequisites
You should already have a FontFace object constructed and understand the difference between FontFaceSet.ready and FontFace.load() — ready resolves once the browser has settled all font matching for currently rendered text, while load() targets one specific FontFace instance regardless of whether it's in use yet. This pattern assumes you're using load() because you want deterministic control over one font object, typically the primary body or heading weight, rather than waiting on the aggregate document.fonts.ready signal. You also need a CSS fallback stack already in place — ideally one whose metrics you've tuned so the swap doesn't cause visible reflow — since the entire point of the timeout is to fall back to it confidently rather than stall.
Implementation
The core idea is Promise.race(): whichever promise settles first — the font finishing its load, or the timer expiring — determines the outcome. Construct the timeout promise so it never rejects the whole chain unexpectedly; instead, resolve it with a sentinel value you can check.
const FONT_LOAD_TIMEOUT_MS = 3000;
function loadFontWithTimeout(fontFace, timeoutMs = FONT_LOAD_TIMEOUT_MS) {
const timeout = new Promise((resolve) => {
setTimeout(() => resolve({ timedOut: true }), timeoutMs);
});
return Promise.race([fontFace.load(), timeout]).then((result) => {
if (result && result.timedOut) {
return { status: 'timeout', fontFace };
}
document.fonts.add(fontFace);
return { status: 'loaded', fontFace };
});
}
const heading = new FontFace(
'Inter',
'url(/fonts/inter-var.woff2)',
{ weight: '400 700', display: 'swap' }
);
loadFontWithTimeout(heading).then(({ status }) => {
const root = document.documentElement;
if (status === 'loaded') {
root.classList.add('fonts-loaded');
} else {
root.classList.add('fonts-failed');
}
});
Line by line: FONT_LOAD_TIMEOUT_MS is set to 3000ms — a reasonable middle ground; 100ms too aggressive for anything but a warm CDN cache, and above roughly 4–5s users perceive the fallback-to-web-font swap as jarring regardless of layout stability. The timeout promise deliberately resolves rather than rejects with { timedOut: true } — using rejection here would force every caller to wrap the call in try/catch and would make Promise.race reject the whole race the instant the timer fires even if the font arrives a moment later (which doesn't matter for our logic, but rejection semantics are easy to get wrong under load). Promise.race settles as soon as either operand settles, so if the font loads in 400ms, the timer keeps running uselessly in the background — that's fine; it stringently doesn't hold up the page, it just fires and its resolution is ignored. The .then() branch checks the sentinel shape rather than relying on instanceof FontFace, because fontFace.load() itself resolves with the FontFace object, not a wrapper — checking for timedOut is unambiguous. On success we explicitly call document.fonts.add(fontFace), because FontFace.load() does not automatically register the font with the document's font set; skipping this step is a common bug where the load "succeeds" but the font never actually renders because nothing added it to document.fonts.
Note that the raw setTimeout handle from the timeout promise keeps running after the race resolves — for a single one-shot load this is harmless, but if you call loadFontWithTimeout inside a loop (e.g., retrying per font weight) you should capture and clearTimeout the handle to avoid a slow leak of pending timers on a page with many font weights.
Defensive Variant: Aborting the Underlying Fetch
The basic race above lets the timer "win" from the JavaScript perspective, but the underlying network request for the font file is still in flight — the browser keeps downloading bytes it will now throw away, and if the connection is contended (e.g., HTTP/1.1 with only six connections per origin), that phantom request can starve other resources like your images or scripts of a socket. FontFace.load() gives you no way to cancel it directly, but you can prevent the situation by pairing the timeout with an AbortController-driven fetch that populates the FontFace source instead of letting load() manage the network itself:
async function loadFontWithAbort(family, url, descriptors, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Font fetch failed: ${response.status}`);
}
const buffer = await response.arrayBuffer();
clearTimeout(timer);
const fontFace = new FontFace(family, buffer, descriptors);
await fontFace.load();
document.fonts.add(fontFace);
return { status: 'loaded', fontFace };
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
return { status: 'timeout' };
}
return { status: 'error', error: err };
}
}
This variant fetches the font bytes yourself with fetch(), passing an AbortController's signal. When the timer fires, controller.abort() actually terminates the in-flight network request — the browser stops pulling bytes and frees the connection slot, which the pure Promise.race version cannot do. Constructing new FontFace(family, buffer, descriptors) with an ArrayBuffer (rather than a CSS url() string) means .load() on that object is now parsing already-downloaded bytes, which resolves near-instantly and rarely needs its own timeout — the network risk has been moved entirely into the fetch() call where AbortController can actually intervene. The catch block distinguishes AbortError (our own timeout firing) from any other fetch failure, such as a CORS violation or a genuine 404, which should probably be logged differently in your error handling pipeline rather than silently treated as a timeout. Always call clearTimeout(timer) on both success and failure paths — leaving the timer armed after a fast successful load means it fires later and calls abort() on a controller nobody is listening to anymore, which is harmless but wasteful, and confusing if you're debugging with the timer callback logging something.
Verification
Open Chrome DevTools, go to the Network tab, and set throttling to "Slow 3G" or add a custom profile with a few seconds of latency. Reload the page and confirm in the Console (with a temporary console.log in the timeout branch) that the fonts-failed class is applied at exactly your configured timeout — use performance.now() timestamps around the loadFontWithTimeout call to confirm the elapsed time is within a few milliseconds of FONT_LOAD_TIMEOUT_MS, not stalled longer. Then switch throttling off, hard-reload, and confirm fonts-loaded is applied well before the timeout, with the visible text already using the web font. For the abort variant, open the Network tab's waterfall during a throttled reload and confirm the font request's status shows as "(canceled)" once the timeout fires, rather than continuing to show bytes transferring after your fallback has already rendered — that cancellation is the concrete signal the AbortController wiring is working, not just the JavaScript logic.
You can also register a PerformanceObserver for resource entries filtered to your font URL to record responseEnd - fetchStart across real page loads, which tells you what timeout budget is actually reasonable for your audience's network conditions rather than guessing — pair this with Real-User Monitoring for Web Fonts if you want field data instead of only lab throttling profiles.
Common Pitfalls
- Rejecting instead of resolving the timeout promise. Using
reject(new Error('timeout'))inside the timer forces every caller intotry/catch, and if you forget one, an unhandled promise rejection surfaces in the console on every slow load even though your fallback logic is working correctly — resolve with a sentinel object instead. - Never calling
clearTimeout. Leaving the timer armed after a fast, successful load means it fires later, uselessly calling.abort()or logging a "timeout" that already lost the race — on a page with manyFontFaceobjects this accumulates into dozens of orphaned timers. - Setting the timeout too low. A 500ms budget will falsely "time out" on any connection with more than trivial latency, causing needless fallback swaps and repeated reflow for users on perfectly normal 4G — measure real p75 font load times with a
PerformanceObserverbefore picking a number, and expect 2500–4000ms for cold-cache 3G scenarios. - Forgetting
document.fonts.add(fontFace)after a successfulload(). The promise resolving does not register the font with the page; skip this line and text stays on the fallback forever despite the "success" branch running, which looks like a timeout bug but is actually a missing registration call. - Racing the promise but not the network request. Without
AbortController, the phantom in-flight download after a timeout can continue consuming a connection slot on HTTP/1.1 origins, delaying other critical resources — use thefetch-plus-AbortControllervariant whenever you're self-hosting and control the fetch path.
Frequently Asked Questions
Does Promise.race cancel the losing promise? No — Promise.race only determines which settled value your .then() callback receives; it does not stop, cancel, or garbage-collect the other promise's underlying work. If the losing promise is fontFace.load(), the browser keeps downloading and parsing the font in the background even after your timeout branch has already run, which is why the AbortController variant exists for cases where that phantom work matters.
What timeout value should I actually use? There's no universal number, but 3000ms is a reasonable default for above-the-fold text on a typical page: it's long enough that fast connections almost never trigger it, and short enough that a stalled request doesn't leave a user staring at invisible or mismatched text for more than a few seconds. If your font-display strategy already uses swap or optional as a CSS-level backstop, you can afford a slightly longer JS timeout, since the browser's own block/swap period is already handling the worst case independently of your script.
Should I retry the load after a timeout, or fall back permanently? For a single page view, fall back permanently and record the failure — retrying inline adds latency the user has already waited through. A better pattern is to record the timeout (e.g., via a beacon or your font error telemetry) and let the next navigation attempt the font fresh, since the underlying network condition that caused the stall has likely changed by then.
Does this pattern work with variable fonts and multiple weights? Yes, but call loadFontWithTimeout once per FontFace instance you construct — if you're loading a single variable font file covering a font-weight range, you typically only need one FontFace object and one timeout, which is simpler than the static-family case where each weight might be a separate file and separate timeout race.