Production engineering guide for font loading strategies covering patterns, implementation strategies, and operational best practices.
Font loading strategies exist because the browser’s default behavior when encountering web fonts is either to hide text until the font arrives (FOIT) or to flash system fonts and replace them later (FOUT). Both patterns affect readability, accessibility, and layout stability. This page organizes strategies by the problem you’re trying to solve: getting critical text on screen immediately, avoiding layout shift, reducing the weight of non‑critical fonts, and working with variable or subsetted fonts. Every example uses standard web primitives—CSS @font-face descriptors, HTML <link> attributes, and the Font Loading API—so you can map them to any stack without needing a product‑specific SDK.
font-display in @font-faceThe font-display descriptor inside @font-face is the most direct lever for controlling the swap timeline. It accepts four keywords that map to the Blink/Gecko rendering timeline, plus a fifth for optional fallback.
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap; /* shows system font immediately, swaps when web font loads */
}
swap — renders text with the available system font immediately; replaces it with the web font once loaded. This produces FOUT but never leaves text invisible.fallback — similar to swap but the swap only happens if the web font can be loaded and rendered within roughly 100 ms. If the font arrives later, the system font stays. This limits layout shift at the cost of potentially never swapping.block — hides text entirely until the web font loads (FOIT). The browser may display a unicode-range subset if one is defined, otherwise text remains invisible.optional — the browser may choose to load the web font or stick with the system font, using a 100 ms decision window. This is useful for secondary text where a missing font is acceptable.When a font is above the fold, preloading it can reduce the time to first render. The <link> element must set as="font" and, for cross‑origin fonts, include crossorigin to avoid CORS errors.
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
The crossorigin attribute triggers a preflight request; if the server responds with Access-Control-Allow-Origin: * (or a specific origin), the browser includes the font in the early fetch. Without it, Safari and Chrome will block the font load with a CORS error.
Exact error text (browser console): Font from origin 'https://example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'https://your-site.com' is therefore not allowed access.
If the font is same‑origin, omit crossorigin; the browser will use credential‑ful fetch automatically.
fetch arrives during parsing; otherwise it runs in parallel. Over‑preloading can delay other critical resources.font-display: optional respects the 100 ms window only on supporting user agents. Older browsers may fall back to block or swap behavior.type="font/woff2" is required for the preload to be recognized as a font in modern browsers; type="application/x-font-woff" is accepted but deprecated.block, fallback, swap in practiceThe choice between these three descriptors is usually a business‑readability trade‑off rather than a technical one.
/* FOUT‑safe: immediate readability, accepts reflow */
html { font-family: "Inter", system-ui, sans-serif; }
/* Minimal reflow, may never swap */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: fallback;
}
/* Invisible text until load completes – use only for above‑the‑fold text where layout is pre‑computed */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: block;
}
When to use each:
| Descriptor | Use when… | Avoid when… |
|---|---|---|
swap | You need immediate readability and can tolerate a single reflow when the font loads. | You cannot afford any layout shift (e.g., fixed‑height containers). |
fallback | You want to limit reflow to the 100 ms window and accept that many users will keep the system font. | Text readability at load time is critical and you cannot accept any invisible period. |
block | You are pre‑computing line‑height/letter‑spacing and can safely hide text until the font is ready (e.g., hero headings with known dimensions). | You need text to be readable during the initial paint (most body copy). |
swap: If the web font is wider or has different metrics than the system font, content may shift vertically or horizontally after the swap. Reserve swap for containers with min-height or use line-clamp to contain overflow.fallback never swapping: On slow networks or with large font files, the 100 ms window may close before the font starts downloading, meaning the system font is used for the entire session. Test on typical client bandwidth.block and CORS: When font-display: block is used with a cross‑origin font that fails CORS, the browser may fall back to a system font silently, or in some cases refuse to load the font altogether, resulting in a missing‑font rectangle.For fonts that aren’t above the fold—hero sub‑heads, sidebar widgets, or non‑essential UI—the Font Loading API lets you initiate the load on your own terms and react to success or failure.
const font = new FontFace("Inter", "url(/fonts/inter.woff2)");
font.load().then(() => {
document.fonts.add(font);
document.documentElement.classList.add("font-active");
}).catch((err) => {
console.error("Font load failed:", err);
// Fall back to system font stack
document.documentElement.classList.add("font-failed");
});
The FontFace constructor accepts a family name and a URL. font.load() returns a promise that resolves when the font is available in the document’s font set, or rejects if the URL returns a 404, the MIME type is unsupported, or a CORS error prevents the fetch.
You can also inspect the font’s status later:
if (document.fonts.check("12px 'Inter'")) {
// font is ready
}
requestIdleCallback or dynamic <style> insertionWhen you want to avoid any render‑blocking behavior entirely, inject the <link> or @font-face rule after the page has painted.
function loadFontLate(href) {
const link = document.createElement("link");
link.rel = "preload";
link.as = "font";
link.href = href;
link.type = "font/woff2";
// crossorigin omitted for same‑origin examples
link.addEventListener("load", () => {
const style = document.createElement("style");
style.textContent = `@font-face { font-family: "Inter"; src: url("${href}"); font-display: swap; }`;
document.head.appendChild(style);
});
document.head.appendChild(link);
}
requestIdleCallback(() => loadFontLate("/fonts/inter.woff2"));
requestIdleCallback queues the function to run during idle periods, ensuring the main thread isn’t blocked during initial paint. The subsequent <style> injection adds the @font-face rule without blocking HTMLParser.
Sharp edges:
FontFace.load() rejects if the network request returns a non‑200 status or if the Content-Type header isn’t font/woff2 (or a recognized variant). A 301/302 redirect without proper Content-Type propagation will cause a catch.document.fonts.check() is not supported in IE or old Android browsers. Feature‑detect before relying on it.<style> insertion does not trigger a new network fetch for the font file itself—that must have been preloaded or fetched earlier. If the font file hasn’t been fetched yet, the @font-face rule will have no visible effect until the font arrives.unicode-range subsettingServing only the glyphs a page actually needs reduces font file size from hundreds of kilobytes to a few kilobytes. Use the unicode-range descriptor inside @font-face to narrow the loaded subset.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-subset.woff2") format("woff2");
unicode-range: U+0025-00FF; /* Latin characters, basic punctuation */
}
If a character falls outside the range, the browser falls back to the next font in the font-family stack.
Concrete outcome: A full Inter WOFF2 might be ~ 85 KB; a unicode-range: U+000-024F subset can drop below 15 KB with negligible visual difference for Latin‑based pages.
Variable fonts consolidate multiple masters (weight, width, optical size) into one file, often reducing total bytes compared to serving separate regular, bold, italic, and condensed WOFF2 files.
@font-face {
font-family: "Inter Var";
src: url("/fonts/inter-var.woff2") format("woff2 var");
font-weight: 100 900;
font-stretch: 75% 125%;
}
The font-variation-settings property in CSS or the font-variation-settings JavaScript API lets you select axes at runtime.
Sharp edges:
unicode-range with local() can cause unexpected matches if a system font shares the same family name and falls within the range. Test font-family priority carefully.src descriptor (font-weight, font-stretch, etc.) or the browser may default to the standard axes (400, 100%). Omitting them gracefully degrades to the nearest master.font-display consistently with variable fonts. swap may behave as block on some iOS versions until the variable font’s master is resolved.Any font fetched from a different origin requires either:
Access-Control-Allow-Origin: * (or the requesting origin) on the server, orIf the header is missing, Chrome and Edge block the load silently (no console error in some versions), while Safari emits the CORS error shown earlier. The only reliable test is to inspect the network request’s Response headers for the Access-Control-Allow-Origin line.
font-display: optional is supported in Chrome ≥ 60, Firefox ≥ 58, Safari ≥ 14, Edge ≥ 17. IE and very old Android browsers ignore it entirely.@fallback font stack in CSS.unicode-range subsetting is widely supported, but unicode-range: U+0025-00FF with font-display: swap has been reported to cause a brief FOIT on Safari prior to version 15.Even with font-display: swap, Cumulative Layout Shift (CLS) can be triggered if the web font’s metrics differ from the fallback. Use font-size-adjust or lock line-height and letter-spacing in your base styles to stabilize container heights during the swap window.
fontloading, fontactive, and fontinactive events (historically part of the Font Loading Module spec) have been deprecated in favor of the document.fonts API. Do not rely on these events in production code; they fire inconsistently across browser versions and are not guaranteed to reflect the exact moment a font becomes usable.
Bottom line: Font loading is a sequence of HTTP fetches, CSS descriptors, and JavaScript timing decisions. Match the strategy to the text’s importance: swap or preload for above‑the‑fold copy, fallback for secondary text, subsetting and variable fonts to tame payload size, and always verify CORS headers and test the 100 ms fallback window on real client profiles.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy