Font Loading Error Handling & Resilience
This guide is part of the Font Performance Monitoring & Auditing area.
Every @font-face declaration is a network request, and every network request can fail. A CDN edge
node can return a 404 for a mistyped hash, a corporate proxy can strip a CORS header and turn a valid
response into an opaque one, a mobile connection can stall mid-download for eight seconds, or a build
can ship a truncated WOFF2 file that fails to parse. None of these are exotic — they show up in real
traffic at a rate of roughly 0.1–0.5% of font requests on any site with meaningful scale, and on a
slow 3G connection a font request has a measurable chance of never resolving inside a session at all.
The default behavior of a browser facing any of these failures is silence: no console error dialog, no
retry, and depending on the font-display value in force, potentially an indefinite block period
where body text renders with visibility: hidden while the browser waits for a resource that is never
coming. Resilient font loading means treating the font request the same way you would treat any other
critical-path network call — with a timeout, an explicit error path, a deterministic fallback, and
telemetry that tells you when it happened. This overview builds that pipeline end to end, and links out
to the two topics that go deeper on detection and on the fallback mechanics themselves.
Why the Browser's Default Behavior Isn't Enough
The CSS Font Loading API gives
you document.fonts, a FontFaceSet that tracks every registered FontFace object and exposes a
ready promise, a status field (unloaded, loading, loaded, error), and a loadingdone /
loadingerror event pair. In principle this is everything you need to detect a failure. In practice,
three properties of the API make naive usage dangerous:
FontFace.load()has no built-in timeout. If the underlying fetch stalls — not fails, just never completes — the returned promise never settles. Code thatawaits it directly will hang forever, and any UI state gated on that await (removing a loading spinner, revealing content) hangs with it.- A CORS failure looks like success at the network layer. The browser fetches the font file in
no-corsrequest mode unlesscrossoriginis set correctly, gets back an opaque response, and then silently refuses to use it for rendering. Noloadingerrorfires for this case on every engine — the font simply never becomes usable, and the fallback in yourfont-familystack renders instead, with no signal that anything went wrong. font-displaytiming and error handling are two separate systems.font-display: swapwill render the fallback during the swap period regardless of whether the real font eventually loads, errors, or hangs — but if it hangs, the swap period ends and the browser is stuck waiting past the period the spec defines, with behavior that varies subtly between engines for how long it keeps trying after the swap window closes.
Because of this, the pattern used across every long-term-resilient font pipeline is: wrap the load in your own timeout, listen for the explicit error events as a secondary signal, and drive one shared "did this font actually become usable" boolean into your CSS and your telemetry.
Baseline Configuration
Start from a font stack where the fallback is metrics-matched (see Accessible Fallback Font Stacks & CLS Prevention) so that whichever branch wins — success or failure — the layout does not jump:
@font-face {
font-family: "Inter";
src: url("/fonts/inter-v12-latin-regular.woff2") format("woff2");
font-weight: 400;
font-display: swap;
size-adjust: 107%;
ascent-override: 90%;
}
html {
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}
html.fonts-failed {
font-family: "Inter Fallback", system-ui, sans-serif;
}
const FONT_TIMEOUT_MS = 2000;
function loadWithTimeout(fontFace, timeoutMs) {
return Promise.race([
fontFace.load(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("font load timeout")), timeoutMs)
),
]);
}
const inter = new FontFace(
"Inter",
"url(/fonts/inter-v12-latin-regular.woff2)",
{ weight: "400", display: "swap" }
);
document.fonts.add(inter);
loadWithTimeout(inter, FONT_TIMEOUT_MS)
.then(() => {
document.documentElement.classList.add("fonts-loaded");
})
.catch((err) => {
document.documentElement.classList.add("fonts-failed");
reportFontFailure(err);
});
The fonts-failed class is the single point of control: it exists whether the failure was a 404, a
CORS block that resolved as a rejected promise, a parse error, or a timeout you imposed yourself. Every
other part of the CSS only needs to know that one boolean.
A Step-by-Step Resilience Workflow
- Register the
FontFaceobjects explicitly rather than relying purely on a<link>or@font-faceblock discovered by the layout engine — this gives you a promise per font instead of only the aggregatedocument.fonts.ready. - Race every load against a timeout using
Promise.race, as shown above and detailed further in A Timeout Pattern for FontFace.load(). A 2–3 second budget is a reasonable default: long enough that a healthy CDN response always beats it, short enough that a stalled connection doesn't leave a user staring at hidden text through an entire session. - Attach a
document.fonts.addEventListener("loadingerror", …)listener as a second signal, covering fonts loaded through native@font-faceparsing rather than your ownFontFacecalls (for example, fonts pulled in by a third-party stylesheet you don't directly control) — see Detecting Failed Font Loads with FontFaceSet for the exact event shape and edge cases across engines. - Branch to a metric-matched fallback, not a bare
sans-serif— an unadjusted fallback swaps x-height, cap-height, and average glyph width all at once and reintroduces the layout shift you were trying to avoid. Graceful Degradation When Fonts Time Out covers building that stack withsize-adjust,ascent-override, anddescent-override. - Report every failure to your telemetry pipeline with enough context — font family, URL, error type, timing — to distinguish "this CDN edge node is unhealthy" from "3% of Safari 15 users on this corporate network always fail this specific font." Pair this with the broader Real-User Monitoring for Web Fonts setup so font failures show up next to your CLS and LCP dashboards rather than in an isolated log.
- Re-render once, not repeatedly. Toggle the class a single time when the promise settles; do not
poll
document.fonts.check()in a loop, which burns CPU and can itself contribute to input delay during the loading window.
Detection Method Compatibility
Different failure causes surface through different signals, and none of the individual signals covers every cause on its own — which is why the workflow above layers a timeout, an event listener, and a promise rejection together rather than relying on any single mechanism.
| Failure mode | Primary signal | loadingerror fires? |
Needs explicit timeout? |
|---|---|---|---|
| 404 / 5xx from origin or CDN | FontFace.load() rejects |
Yes | No |
| Connection stalls indefinitely | Neither — promise never settles | No | Yes |
| CORS misconfigured (missing header) | Opaque response, silent fallback | Inconsistently | No, but timeout still helps bound it |
| Truncated / corrupt font file | Parse error, load() rejects |
Yes | No |
| DNS failure to font CDN | Network error, load() rejects |
Yes | No |
The stalled-connection row is the one every naive implementation misses, because it produces no error at all — only the absence of a resolution. That absence is exactly what the timeout in the workflow above is designed to catch.
Code Example: Per-Family Failure Isolation
When a page loads more than one font family — a display face and a body face, for instance — treat
each FontFace independently so that one slow CDN edge doesn't take down a family that loaded fine:
const families = [
{ name: "Inter", url: "/fonts/inter-regular.woff2", cssVar: "--font-body" },
{ name: "Fraunces", url: "/fonts/fraunces-display.woff2", cssVar: "--font-display" },
];
async function loadAllFonts() {
const results = await Promise.allSettled(
families.map(async (f) => {
const face = new FontFace(f.name, `url(${f.url})`, { display: "swap" });
document.fonts.add(face);
await loadWithTimeout(face, 2000);
return f;
})
);
results.forEach((result, i) => {
const family = families[i];
const root = document.documentElement;
if (result.status === "fulfilled") {
root.style.setProperty(family.cssVar, `"${family.name}", var(${family.cssVar}-fallback)`);
} else {
reportFontFailure({ family: family.name, error: String(result.reason) });
// CSS custom property is simply left at its fallback default.
}
});
}
loadAllFonts();
Promise.allSettled is the key choice here over Promise.all: a single rejected font must not reject
the whole batch and leave every family — including the ones that loaded successfully in time — stuck
without their custom property set.
Code Example: Detecting a CORS-Masked Failure
A cross-origin font served without the right Access-Control-Allow-Origin header, or fetched without a
matching crossorigin attribute, produces no exception and no loadingerror in several engine
versions — the browser just quietly never uses it. Detect it by checking document.fonts.check()
after the load promise resolves, not just whether the promise itself settled:
async function loadWithCorsCheck(face, cssTestString) {
document.fonts.add(face);
try {
await loadWithTimeout(face, 2000);
} catch (err) {
return { ok: false, reason: "load-rejected", err };
}
// The promise can resolve even for an opaque, unusable response in some cases.
const usable = document.fonts.check(`16px "${face.family}"`);
if (!usable) {
return { ok: false, reason: "cors-or-parse-silent-failure" };
}
return { ok: true };
}
Full CORS configuration on the delivery side — matching crossorigin="anonymous" on the tag with an
Access-Control-Allow-Origin response header from the CDN — is covered in
Serving Fonts from a CDN with Correct CORS;
this snippet is the client-side check that catches the cases where that configuration is wrong in
production despite looking correct in a staging environment with a permissive proxy.
Code Example: Reporting to a RUM Endpoint
Failures are only actionable once they're aggregated. A minimal reporting function that avoids
flooding the network with per-failure requests batches into navigator.sendBeacon:
const fontErrorQueue = [];
function reportFontFailure(detail) {
fontErrorQueue.push({
family: detail.family ?? "unknown",
reason: detail.reason ?? String(detail.err ?? detail),
ts: Date.now(),
connection: navigator.connection?.effectiveType ?? "unknown",
});
}
window.addEventListener("pagehide", () => {
if (fontErrorQueue.length === 0) return;
navigator.sendBeacon(
"/rum/font-errors",
JSON.stringify({ errors: fontErrorQueue, url: location.pathname })
);
});
Tagging each error with navigator.connection.effectiveType is what turns a raw error count into an
actionable signal — a cluster of timeouts on "2g" connections tells you the timeout budget itself may
be too aggressive for that segment, whereas a cluster of 404s on "4g" points at a CDN or build problem
instead.
Error Handling Across Loading Contexts
The pattern above assumes you control the loading code directly, but a growing share of production
sites load fonts through a framework layer instead of hand-rolled FontFace calls. That layer usually
still funnels down to the same underlying FontFaceSet, so the same detection principles apply — only
the place you attach the timeout and the fallback moves.
Framework-managed fonts. Tools covered in
Loading Web Fonts in JavaScript Frameworks,
such as Next.js Font Optimization with next/font,
generate the @font-face rule and the fallback metrics for you at build time, which removes most
manual error-prone configuration — but they do not add a runtime timeout or an error listener. If you
need resilience against a slow or failing CDN under a framework-managed font, you still register a
parallel document.fonts.addEventListener("loadingerror", …) listener yourself and drive your own
fonts-failed class, exactly as in the baseline configuration, because the framework's build-time
output has no visibility into a runtime network failure.
Third-party stylesheets you don't control, such as a Google Fonts <link> embedded by a marketing
tag manager, cannot be wrapped in your own FontFace.load() call at all, because you never construct
the FontFace object yourself — the browser does, from the CSS. In that situation the
document.fonts.addEventListener("loadingerror", …) listener is your only hook, since there is no
promise of your own to race against a timeout. Treat any font loaded this way as lower-confidence for
error detection, and prefer self-hosting critical fonts (see
Google Fonts vs Self-Hosting) precisely
so that you retain the ability to wrap the load in your own resilience code.
Server-rendered pages with a font-loading class baked into the initial HTML — for example, a
two-stage render that reads a sessionStorage flag from a previous successful load — need one more
guard: verify on the current page load that the font is still actually usable with
document.fonts.check() before trusting a stale flag from a previous session, since a font that loaded
successfully yesterday can still fail today if the CDN has since had an outage.
Testing the Failure Path in CI
Because font failures are rare in a healthy environment, the failure branch of this code is exactly the kind of logic that silently rots without an automated check. Two low-effort additions catch regressions before they reach production:
- A unit test around the timeout wrapper itself, independent of any real network call — mock
FontFace.load()to return a promise that never resolves, and assert thatloadWithTimeoutrejects within the configured budget rather than hanging the test runner. - A synthetic Lighthouse or Playwright run with the font request blocked, mirroring the manual DevTools check from the Verification section but scripted so it runs on every pull request. Combine this with the broader Lighthouse Font Audits in CI setup so a regression in the fallback path fails the build the same way a font-display regression does, rather than only being caught by a human clicking through DevTools after the fact.
Verification
Confirm the pipeline behaves correctly under failure with a DevTools network override rather than waiting for a real CDN outage:
- Open DevTools → Network, right-click the font request, and choose Block request URL (or throttle to a custom profile with added latency past your timeout budget).
- Reload the page and confirm
html.fonts-failedis applied within your configured timeout window — inspect theclassattribute on<html>in Elements, or logperformance.now()at the moment the class is added. - Confirm visually that body text is never invisible: with a metric-matched fallback in place, the
fonts-failedbranch should render text at essentially the same size and position as the successful branch, which you can check with the Layout Shift Regions overlay in the Rendering panel — it should not highlight anything during the swap. - In the Console, run
document.fonts.statusmid-load to confirm it reports"loading", and after settling confirmdocument.fonts.check('16px "Inter"')returnsfalsein the blocked scenario andtruein a normal load, matching the class that was applied.
Common Pitfalls
- Awaiting
FontFace.load()with no timeout at all. A single stalled connection then blocks whatever UI logic depends on that await indefinitely — no error, no fallback, just a permanently pending promise. Always wrap it inPromise.racewith an explicit deadline. - Falling back to unadjusted
sans-serifon error. This trades an invisible-text problem for a layout-shift problem: the fallback's different x-height and glyph widths reflow every line the instant the class flips. Use asize-adjust-tuned fallback as described in Calculating size-adjust for a system-ui Fallback. - Trusting
loadingerroralone. It does not reliably fire for CORS-masked failures on every engine, so a pipeline that only listens for that event will report zero failures while users are silently served the fallback font with no record of why. - Retrying failed loads immediately in a loop. Retrying the exact same URL against a CDN edge node that just 404'd or timed out rarely succeeds and multiplies request volume during an incident; if you retry at all, do it once, after a short delay, and only for network-level failures, not for CORS or parse errors that will not change on retry.
- Polling
document.fonts.check()on an interval instead of using events. This wastes main-thread time during the exact window when you're also trying to keep input responsive, and it adds jitter to the moment thefonts-loadedclass is actually applied. - Not testing the failure path at all. Font error handling code is the least-exercised branch in most codebases because CDNs are usually healthy; without deliberately blocking the request in DevTools during development, the fallback branch often has bugs that only surface during a real incident.
Frequently Asked Questions
Does font-display: swap already handle failures for me?
No. font-display only governs the block and swap periods relative to a successful (or still
in-flight) load — it says nothing about what happens if the load never completes or errors out. A
swap value will show the fallback during the swap period, but if the real font hangs past that
period some engines keep waiting far longer than users will tolerate, and none of this produces the
explicit signal your own code needs to log the failure or force a permanent fallback.
What timeout value should I use? Two to three seconds is a reasonable default for most sites: longer than any P95 CDN response time on a healthy connection, short enough to bound worst-case invisible or swapped-late text to something a user will not perceive as broken. Validate the choice against your own Measuring Font Loading Performance data rather than guessing — if your P99 load time on 3G is 2.4s, a 2s timeout will misfire for a meaningful slice of real users.
Do I need this if I only self-host fonts with no third-party CDN? Yes, though the failure rate is lower. Self-hosted fonts still fail from origin server outages, bad deploys that ship a corrupted file, misconfigured cache headers that serve a stale 404, or the same CORS mistakes if fonts are served from a different subdomain or asset host than the page itself.
How is this different from the fallback logic in FOUT/FOIT mitigation? FOUT vs FOIT Mitigation covers the normal, successful-load timeline — controlling when the swap happens. This guide covers the abnormal path: what happens when the load never successfully completes at all, which needs its own timeout, its own event handling, and its own telemetry independent of the font-display timing model.