FontFaceSet.ready vs FontFace.load()

Both document.fonts.ready and document.fonts.load() return a promise, both come from the CSS Font Loading API, and engineers reach for them interchangeably — then ship a layout trigger that fires too late, or a canvas render that draws in the wrong typeface. The two are not synonyms. FontFaceSet.ready is a single global signal that resolves once when every pending font has settled; document.fonts.load() (and the lower-level FontFace.prototype.load()) is a targeted request that downloads and awaits one specific font. Picking the wrong one is the difference between a clean swap and a frame of fallback text.

This guide is part of the CSS Font Loading API Implementation workflow, which sits under the broader Font Loading & Delivery Strategies blueprint. Read this when you need to decide which promise drives your class swap, your <canvas> text draw, or your measurement pass.

Problem statement

document.fonts.ready resolves after the browser has finished loading all fonts that layout currently requires — it is a "the page's fonts are settled, paint is stable" signal with no argument. document.fonts.load('16px Inter') resolves after that one font (matched by the same shorthand syntax as the CSS font property) is downloaded and parsed, and hands you back the matched FontFace objects. If you use ready to gate a single off-screen canvas draw, you wait on fonts that draw has no interest in; if you use a targeted load() to gate a global "fonts arrived" class, you miss every other family on the page. Choose by intent: global layout trigger versus targeted resource.

Prerequisites

  • Your web fonts are declared with @font-face (or constructed FontFace objects added to document.fonts). The API only resolves against fonts the browser already knows about.
  • A modern browser: document.fonts ships in Chrome 35+, Firefox 41+, Safari 10+, and Edge 79+. There is no production-grade polyfill — feature-detect with if (document.fonts) instead.
  • The font-family string you pass to load() must match the @font-face font-family exactly, including casing and quoting rules. document.fonts.load('16px Inter') will never resolve against a face declared as font-family: "Inter Variable".
  • For lazy class-swap strategies, pair this with font-display values so text stays visible while the promise is in flight.
FontFaceSet.ready versus document.fonts.load A comparison: ready resolves once when all pending fonts settle, while load targets a single named font and returns the matched FontFace objects. document.fonts (FontFaceSet) Inter 400 Inter 700 Lora 400 ready: one global signal resolves once ALL above settle load('16px Inter') awaits ONE family returns FontFace[] Use ready for a page-wide layout trigger. Use load() for a targeted canvas / measure pass.
One settles the whole set; the other targets a single named font and returns it.

Implementation

The primary pattern below shows both calls side by side so the contract of each is unambiguous. The global trigger drives a class swap on <html>; the targeted load gates a canvas draw that must not paint in the fallback typeface.

Both patterns side by side, annotated

// ── Pattern A: GLOBAL layout trigger ──────────────────────────────
// FontFaceSet.ready is a property, not a method. It returns a promise
// that resolves to the FontFaceSet itself once EVERY font the page is
// currently waiting on has either loaded or failed. No argument — it is
// a "fonts have settled, layout is stable" signal.
document.fonts.ready.then((fontFaceSet) => {
  // Safe place to flip a global class: all pending faces are done,
  // so the swap reflows once instead of per-font.
  document.documentElement.classList.add('fonts-loaded');
  // fontFaceSet.size === number of FontFace objects now resolved
});

// ── Pattern B: TARGETED load of a SPECIFIC font ───────────────────
// document.fonts.load(font, text?) takes a CSS `font` shorthand string.
// It STARTS the download for the matched face(s) and resolves with an
// array of the FontFace objects that matched. Use it when one specific
// font must be ready before a discrete operation.
async function drawLabel(ctx) {
  // The '16px Inter' string is parsed like the CSS `font` property:
  // size is required, family must match the @font-face exactly.
  await document.fonts.load('700 16px Inter', 'Revenue');
  // Now — and only now — is Inter 700 guaranteed available for canvas.
  ctx.font = '700 16px Inter';
  ctx.fillText('Revenue', 24, 40);
}

Pattern A never takes an argument and resolves once per stable state — it is the right tool for "the page's fonts arrived, apply the swap." Because it batches, you get a single reflow instead of one per family. Pattern B takes a font shorthand (<weight> <size> <family>, size mandatory) plus an optional text string that lets the browser load only the glyphs you name; it returns the matched FontFace[], and crucially it initiates the fetch — so a targeted load() is also how you eagerly warm a font you know you'll need.

The lower-level equivalent of Pattern B works directly on a FontFace instance when you constructed it in JS rather than via @font-face:

FontFace.load() on a constructed face

const inter = new FontFace('Inter', 'url(/fonts/inter-var.woff2)', {
  weight: '100 900',
});
document.fonts.add(inter);          // register so layout can use it
const loaded = await inter.load();  // resolves with the same FontFace
// inter.status is now 'loaded' (was 'unloaded' before the call)

FontFace.load() and document.fonts.load() do the same fetch; the difference is granularity. Call the instance method when you hold the object; call the set method when you only know the family name.

Timeout / error handling variant

Neither promise rejects on a slow network — document.fonts.ready simply stays pending while a font is still downloading, and a targeted load() rejects only on a genuine NetworkError (404, CORS failure, corrupt file), not on slowness. So a naive await can hang your canvas draw indefinitely on a high-latency connection. Wrap targeted loads in a Promise.race with a timeout so the operation always proceeds, in the web font or the fallback.

Defensive load with Promise.race timeout

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('font-timeout')), ms)
  );
  return Promise.race([promise, timeout]);
}

async function safeDraw(ctx) {
  try {
    // Cap the wait at 2s. If Inter isn't ready by then, we render in
    // the fallback rather than leaving the canvas blank.
    await withTimeout(document.fonts.load('700 16px Inter', 'Revenue'), 2000);
    ctx.font = '700 16px Inter';
  } catch (err) {
    // Timeout OR NetworkError both land here — degrade gracefully.
    ctx.font = '700 16px system-ui, sans-serif';
    performance.mark('font-fallback-canvas'); // for RUM correlation
  }
  ctx.fillText('Revenue', 24, 40);
}

The same guard applies to the global signal: if you gate first paint or LCP on document.fonts.ready, race it against a timeout and add the fonts-loaded class on whichever wins, so a single stuck font cannot block the entire page. Never leave text invisible or a canvas blank waiting on a promise that may never settle.

Verification

Confirm the distinction at runtime before trusting either path:

  • document.fonts.check() — synchronous, returns a boolean without triggering a download. document.fonts.check('16px Inter') tells you whether that exact face is already available for layout. Run it before and after a targeted load() to prove the call did the work: false before, true after the promise resolves.
  • Console timingdocument.fonts.ready.then(() => console.timeStamp('fonts-ready')) drops a marker on the DevTools Performance timeline so you can see exactly when the global settle fires relative to LCP.
  • document.fonts.status — reads 'loading' while any face is pending and flips to 'loaded' at the same instant ready resolves; watch it in the console to confirm the global signal's meaning.
  • DevTools Network tab — filter by Font. A targeted load('16px Inter') produces exactly one request for the matched face; ready produces none on its own (it only observes). If you see a request appear the moment you call load(), you've confirmed it initiates the fetch.

Common pitfalls

  • Treating ready as a method. It is a property: document.fonts.ready.then(...), never document.fonts.ready().then(...). The latter throws TypeError: document.fonts.ready is not a function.
  • Omitting the size in the load shorthand. document.fonts.load('Inter') throws a SyntaxError — the font shorthand requires a size, so write '16px Inter' even when the size is irrelevant to your check.
  • Using ready to gate one off-screen draw. It waits on every page font, including ones your canvas never touches, delaying the draw needlessly. Use a targeted load() for discrete operations.
  • Expecting load() to reject on slow networks. It only rejects on real network errors. Without a Promise.race timeout, your await can hang for the life of the connection.
  • Mismatched family strings. load('16px "Inter Var"') against an @font-face named Inter resolves with an empty array and never makes your font available — the call "succeeds" but does nothing useful.

FAQ

Does FontFaceSet.ready re-resolve when I add fonts later? No. Each access of document.fonts.ready returns a promise for the current settle. After it resolves, dynamically adding and loading a new FontFace produces a fresh promise on the next access — the original resolved promise does not fire again. For fonts added after initial settle, await your targeted load() directly instead of relying on ready.

Why does document.fonts.load() return an array instead of a single font? A single family name plus size can match multiple @font-face rules — for example different unicode-range subsets or styles. load() resolves with every FontFace that matched the shorthand, so a Latin-plus-Cyrillic split family hands back two objects. Iterate the array if you need each one's status.

Should I block hydration or LCP on either promise? No. Gating framework hydration or first paint on document.fonts.ready delays interactivity and LCP. Set font-display values to keep text visible, let the page render in the fallback, and use the promises only to enhance — flip a class or redraw a canvas — once fonts arrive.

Related