Detecting Failed Font Loads with FontFaceSet

This guide is part of the Font Loading Error Handling & Resilience topic, itself under Font Performance Monitoring & Auditing.

A <link rel="preload"> that 404s, a CDN edge node that drops a WOFF2 request, or a CORS misconfiguration that turns a font fetch into an opaque failure all produce the same silent symptom: the browser quietly falls back to its default font stack and nothing in your console tells you why. Unless you explicitly instrument the failure path, the first sign of trouble is usually a support ticket about "wrong-looking text" weeks after a CDN migration broke one font weight. The Font Loading API exposes exactly the events and promise rejections you need to catch this at the moment it happens — this guide covers FontFaceSet's loadingerror event, per-FontFace load() rejections, and how to route a detected failure into a deliberate, metric-matched fallback instead of an accidental one.

Prerequisites

You should already have fonts registered through @font-face or constructed programmatically with the FontFace constructor, and be somewhat familiar with the document.fonts (FontFaceSet) interface — if you haven't set up basic font loading with the API yet, start with the CSS Font Loading API implementation guide. You'll also want a font-display value already chosen (see font-display values explained), since failure detection and font-display behavior interact: a swap or optional value determines what the user sees while you're still waiting to find out whether the load actually failed.

Two independent surfaces report font failures, and you need both because they don't overlap perfectly:

  1. FontFace.load() rejection — when you explicitly call .load() on a FontFace object you constructed yourself, the returned promise rejects if the fetch fails (network error, 404, blocked CORS response) or the binary can't be parsed as a valid font.
  2. FontFaceSet.loadingerror event — fired on document.fonts whenever any face registered on the set (including ones declared purely in CSS via @font-face, which never had .load() called on them explicitly) fails to load once the browser attempts to use it.

If you only listen for one, you'll miss failures from the other loading path. A page that mixes CSS-declared @font-face blocks with a JS-driven critical-path preload needs both listeners wired up.

Support for the FontFaceSet interface, including loadingerror, is broad across evergreen browsers — Chrome, Firefox, Safari, and Edge have all shipped it for years — so the gap you need to plan around isn't availability, it's consistency in exactly which face-level details the event exposes. Some engines populate a fontfaces array on the event with references to the specific FontFace objects that failed; others require you to re-scan document.fonts yourself, as the implementation below does defensively. Treat the event as a reliable "something failed" signal and use .status inspection as the reliable "which face" signal, rather than trusting event-payload shape to be identical everywhere.

Signal Fires for CSS-declared @font-face? Fires for JS-constructed FontFace? Distinguishes timeout from hard error?
FontFaceSet.loadingerror Yes, once requested Yes No — reports failure only
FontFace.load() rejection No (never called) Yes No — reports failure only
Promise.race() timeout wrapper N/A (JS pattern) Yes Yes — timeout raises its own reason

That table is the reason the implementation below layers a manual timeout on top of the two built-in signals: neither native mechanism alone tells you why a face never became usable, only that it didn't.

Implementation

The most common setup: fonts declared in CSS, with JavaScript observing the set for failures and toggling a class that switches the fallback stack.

function watchFontFailures(family, weight = "400") {
  const failed = new Set();

  document.fonts.addEventListener("loadingerror", (event) => {
    // event.fontfaces is the standard field name in the spec draft;
    // Chrome/Firefox currently expose the failed face via event.target
    // combined with a manual check against document.fonts.
    for (const face of document.fonts) {
      if (face.status === "error" && face.family.replace(/["']/g, "") === family) {
        failed.add(`${face.family} ${face.weight} ${face.style}`);
      }
    }
    if (failed.size > 0) {
      document.documentElement.classList.add("fonts-failed");
      reportFontFailure(family, weight, [...failed]);
    }
  });

  // Trigger the browser into actually attempting the load; loadingerror
  // will not fire for a face that is never requested.
  document.fonts.load(`${weight} 1em "${family}"`).catch(() => {
    // load() also rejects directly — belt-and-suspenders with the event above
    document.documentElement.classList.add("fonts-failed");
  });
}

function reportFontFailure(family, weight, faces) {
  if (navigator.sendBeacon) {
    navigator.sendBeacon(
      "/rum/font-error",
      JSON.stringify({ family, weight, faces, url: location.pathname, ts: Date.now() })
    );
  }
}

watchFontFailures("Inter", "400");
.fonts-failed body {
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

Line-by-line: document.fonts is the page's single FontFaceSet, shared across every @font-face rule and every FontFace object you construct. The loadingerror listener fires once per face that fails once the browser attempts to render text with it — but because the event object's face-attribution field is still inconsistently implemented across engines, the safe pattern is to re-scan document.fonts for any face whose .status is "error" rather than trust the event payload alone. document.fonts.load() is what forces the browser to actually attempt the fetch for a face that's only declared via @font-face (which is otherwise lazy — a browser won't fetch a face until it needs to paint with it, or until you call .load()). The .catch() on that call is a second, more reliable signal for the specific request you triggered. reportFontFailure uses sendBeacon so the report survives page unload, which matters because failures often correlate with poor network conditions that also delay unload-triggered analytics.

A Defensive Variant with Per-Face Tracking and Timeout

The listener above tells you that something failed, but for a multi-weight family you often need to know which weights degraded so you can decide whether the page is still usable. This variant tracks each FontFace individually and combines rejection detection with a load timeout, so a font that neither succeeds nor explicitly errors — a request that just hangs on a flaky connection — is still caught.

async function loadWithFailureDetection(fontFace, { timeoutMs = 4000 } = {}) {
  document.fonts.add(fontFace);

  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("font-load-timeout")), timeoutMs)
  );

  try {
    await Promise.race([fontFace.load(), timeout]);
    return { family: fontFace.family, weight: fontFace.weight, ok: true };
  } catch (err) {
    document.fonts.delete(fontFace); // stop the browser from retrying this face
    return {
      family: fontFace.family,
      weight: fontFace.weight,
      ok: false,
      reason: err.message === "font-load-timeout" ? "timeout" : "load-error",
    };
  }
}

async function loadFamily(spec) {
  const results = await Promise.all(
    spec.map(({ family, weight, url }) =>
      loadWithFailureDetection(new FontFace(family, `url(${url})`, { weight }))
    )
  );

  const failures = results.filter((r) => !r.ok);
  if (failures.length > 0) {
    document.documentElement.classList.add("fonts-failed");
    navigator.sendBeacon?.("/rum/font-error", JSON.stringify({ failures, url: location.pathname }));
  }
  return results;
}

loadFamily([
  { family: "Inter", weight: "400", url: "/fonts/inter-400.woff2" },
  { family: "Inter", weight: "700", url: "/fonts/inter-700.woff2" },
]);

This is close to the defensive timeout pattern for FontFace.load(), extended to report why each face failed rather than only whether the overall family degraded. Distinguishing "timeout" from "load-error" matters operationally: a spike in timeouts usually points at a CDN or edge cache problem (see edge-caching fonts for faster first paint), while a spike in outright load-error more often means a broken URL or a CORS misconfiguration on the response (covered in serving fonts from a CDN with correct CORS). Deleting the failed face from document.fonts after a timeout also prevents the browser from silently retrying the same doomed request on a later navigation within the same session.

Font failure detection flow Numbered steps showing the sequence from requesting a font to reporting and falling back on failure. Font failure detection flow 1 Register FontFace @font-face or JS 2 Call load() forces fetch 3 Race vs timeout 4s deadline 4 Catch rejection or loadingerror 5 Add fonts-failed class CSS fallback 6 Send beacon report sendBeacon
How a failed font request is caught and routed to a deliberate fallback.

Verification

Confirm detection actually fires before you ship it, using two independent methods:

  1. Force a failure in DevTools. Open the Network panel, find the font request, right-click it, and choose "Block request URL" (Chrome) or throttle to offline mid-load. Reload the page and confirm your fonts-failed class appears in the Elements panel and that a beacon request to your reporting endpoint appears in the Network panel.
  2. Inspect document.fonts state directly in the console after the page settles:
[...document.fonts].map(f => ({ family: f.family, weight: f.weight, status: f.status }));
// expect status: "loaded" for healthy faces, "error" for the one you blocked
  1. Check the fallback actually renders correctly. With the font blocked, use the Rendering pane's "Emulate CSS media feature" tools or simply inspect computed styles on body text to confirm font-family resolved to your intended fallback, not an unstyled browser default — this is where a properly designed accessible fallback font stack pays off, since the visual difference between "fallback chosen deliberately" and "fallback landed by accident" should be small.

If you're also tracking this in the field, cross-reference detected failures against PerformanceObserver-based load timing — a font that "fails" after 30 seconds on a resource-timing entry is a very different problem from one that fails after 40ms with a 404.

Detection signal comparison Comparison table of three font failure detection signals against three capabilities. Detection signal comparison Catches 404 Catches CORS block Catches hang/timeout loadingerror event Yes Yes No load() rejection Yes Yes No Promise.race timeo… Indirect Indirect Yes
Native events vs a manual timeout wrapper cover different failure modes.

Common Pitfalls

  • Never calling .load() on CSS-declared faces. A face declared purely via @font-face is lazy — the browser won't attempt to fetch it until it's about to paint text with it, so loadingerror won't fire proactively. If you want early detection (e.g., during a splash screen), explicitly call document.fonts.load() for the family/weight combinations you care about.
  • Assuming a rejected FontFace.load() promise means the network request failed. It also rejects for a syntactically valid but corrupt font binary, or when the browser's parser rejects the file for a hinting/table error — treat "load rejected" as "unusable," not strictly "network error," when deciding what to log.
  • Not distinguishing timeout from hard failure. A loadingerror event and a client-side timeout look identical from the "text is unstyled" perspective but point at different root causes — collapsing them into one bucket in your telemetry makes root-causing a CDN regression much slower.
  • Forgetting crossorigin on cross-origin font requests, which turns a would-be successful load into a CORS-blocked failure that reports through the exact same loadingerror path, masking a config problem as a "flaky font." Confirm your CDN emits the correct Access-Control-Allow-Origin header before assuming the failure is transient.
  • Reporting failures without rate-limiting. A CDN outage affecting every visitor for ten minutes can flood your analytics endpoint with beacons; batch or sample font-failure reports the same way you would any other RUM signal, especially if you're also running Real-User Monitoring for web fonts that already samples other metrics.

Frequently Asked Questions

Does loadingerror fire for fonts that are never actually requested by the page? No. If no visible text ever uses a given @font-face declaration and you never call .load() on it, the browser has no reason to fetch it, so there is nothing to fail — loadingerror only fires for faces the browser actually attempted to load. This is why a font that "silently disappeared" after a CSS refactor sometimes turns out to have simply stopped being referenced by any selector, not to have failed a network request.

Can I detect a failure before the user sees the fallback text render? Only if you initiate the load proactively (via document.fonts.load()) ahead of the point where the browser would otherwise attempt it during paint, and gate the visible render on that promise settling — which is exactly the two-stage pattern used for eliminating FOUT with the CSS Font Loading API. If you let font-display: swap do the fallback-then-swap dance on its own timeline, the fallback will already be visible by the time your loadingerror handler runs.

Is there a way to retry a failed font load automatically? There's no built-in retry in the Font Loading API — a face that errors stays in the "error" state until you construct and add a new FontFace instance pointing at (ideally) an alternate URL. A common pattern is to keep a secondary CDN or same-origin fallback URL and retry once through a fresh FontFace object on loadingerror, then give up and rely on the CSS fallback stack if the retry also fails.

Do I need separate handling for woff2 vs woff fallback sources declared in one src list? No — the browser resolves the src list itself and only reports success or failure for the family/weight as a whole once it has tried every listed format; you don't get a separate event per format attempt. If every format in the list is unreachable, that's when loadingerror fires for that face.

Time to detect failure Bar chart comparing detection latency for a 404, a CORS block, and a hung connection. Time to detect failure 404 response ~120ms CORS block ~150ms Hung connection 4000ms t… time to detection
A hung request is only caught once the timeout wrapper elapses.

Related