Web Fonts Not Loading: A Troubleshooting Guide for @font-face Failures (2026)
The page is live, the CSS is correct as far as anyone can tell, and the headings are sitting in a fallback font. Or the text vanishes for two seconds on every load. Or bold is a smeared imitation of bold. Web font failures feel mysterious because the browser rarely says no out loud: it quietly falls back and carries on. They are not mysterious. There are a small number of ways an @font-face font can fail, each one leaves a different fingerprint, and checking them in the right order finds the cause in minutes.
This guide is the diagnostic. It starts with the eight checks in order, then works through the failure modes by symptom, because symptom is what you actually have. It assumes the fonts themselves are licensed and chosen; the font formats guide covers which file you should be serving, and the Google Fonts guide covers the hosted-library version of this setup.
The Eight Checks, in Order
Run these top to bottom. Each check eliminates a whole class of cause, and the order goes from most common to most subtle.
- Is the font file requested at all? Open the browser's network panel, filter to fonts, reload. No request means the @font-face rule is never matched (wrong family name, wrong weight, or the CSS itself is not loaded). A request with a 404 means the path is wrong.
- Does the requested URL return the font? Open the URL directly. An HTML page instead of a font download means a server fallback is rewriting the path; binary gibberish is what a font looks like, and that is good.
- Does the family name in @font-face match the name used in font-family, exactly? The browser matches the string you declared, and headings styled with a slightly different name fall back silently.
- Does the format() hint match the actual file? A TTF labelled format("woff2") is skipped or rejected. The hint is a promise about the bytes, not a wish.
- If the file is on another origin, does the response carry Access-Control-Allow-Origin? Font fetches are CORS requests. A cross-origin font without that header is blocked even though the URL works when opened directly.
- Is the text invisible rather than wrong? That is not a loading failure, it is font-display doing what it was told. Check the value before touching anything else.
- Is the right file loading but the wrong weight showing? Only one weight declared, or weights declared under mismatched numbers, makes the browser synthesise bold and italic. Check what is declared against what the design uses.
- Does the console report a font parse error? A corrupt file, a bad subset, or an HTML error page served in place of the font all end at the browser's font sanitiser, which refuses the file and logs why.
Symptom: Everything Falls Back, No Font Request in the Network Panel
The rule is never used. The usual causes, in order: the stylesheet containing @font-face is not the stylesheet the page loads (cached file, wrong build output, a second CSS file shadowing the first); the font-family string in your element styles does not match the family name declared in @font-face, including extra spaces or a renamed family like "Example Sans Web" in the declaration against "Example Sans" in the styles; or the text is styled at a weight or style with its own rule that points elsewhere. Fix the match and the request appears. Nothing about the font file matters until the browser asks for it.
Symptom: The Font Request 404s
@font-face URLs resolve relative to the CSS file that declares them, not relative to the page. A rule moved from an inline style block into /css/site.css quietly changes what ../fonts/ means. This is the single most common cause of "it worked in the mockup". Give the URL from the CSS file's location, or use a root-relative path (/fonts/example.woff2) and remove the ambiguity. The second form of this failure is the helpful server: single-page-app and static-host fallback rules rewrite unknown paths to index.html, so the font URL returns 200 and an HTML document, and the browser's font sanitiser rejects it (check 8 catches this in the console). The fix is at the host: real files must be served as files, and the fallback must exclude asset paths.
Symptom: Works on Your Domain, Blocked From the CDN
Fonts are fetched in CORS mode, always. Serve a font from a CDN, a storage bucket, or a fonts subdomain without Access-Control-Allow-Origin and the browser blocks it, typically with a CORS error in the console naming the font URL, while the same URL downloads fine when pasted into the address bar (that request is not a font fetch). The fix is one header on the font responses: Access-Control-Allow-Origin: * is acceptable for fonts, which carry no credentials, or the specific site origin. The same rule explains the classic preload double download: a for a font that does not match the font fetch's CORS mode downloads the file twice, once for the preload and once for the real request. Font preloads need crossorigin on the link element, even on the same origin, because that is the mode the font itself will be fetched in.
Symptom: Text Is Invisible, Then Pops In
Nothing is broken. This is FOIT, the flash of invisible text, and it is font-display behaviour working as specified. web.dev's font loading documentation describes the timeline every font-display value runs: a block period, during which text using the font is invisible while the browser waits; a swap period, during which a fallback shows and the web font can still replace it; and after that, the fallback stays for that page view. The values set the lengths:
- block: a long block period and an endless swap period. Invisible text, then the real font whenever it arrives. Right for almost nothing on the open web.
- swap: no block period to speak of and an endless swap period. Fallback text immediately, replaced when the font lands (that replacement is FOUT, the flash of unstyled text). The standard choice for content sites.
- fallback: a brief block period and a short swap period. If the font is quick, it is used; if not, the fallback stays for the page view.
- optional: a very short block period (about 100 milliseconds in Chrome's implementation, per web.dev) and no swap period. The font is used only if it is effectively already there, which is why web.dev pairs optional with preload as the combination that removes layout shift entirely.
- auto / unset: the browser chooses, and most choose something close to block. Leaving font-display unset is choosing invisible text by default.
The fix is a decision, not a repair: pick swap when the brand font must always arrive, optional when stability matters more than the font on slow connections, fallback between them. If the text is invisible and no font-display is set anywhere, check whether a CSS framework or a font service stylesheet is setting block on your behalf.
Symptom: The Right Font Arrives and the Page Jumps Anyway
The font loads, the swap happens, and the text reflows hard enough to move the button the reader was about to tap. This is FOUT with bad manners: the fallback font and the web font have different metrics, different x-heights, ascent and line heights, so every line the web font touches is re-laid-out at a slightly different size, and everything below it shifts. Swapping the font-display value does not fix it; the problem is not when the font arrives but that the two fonts occupy space differently.
The fix works on the fallback side, and SitePoint's web font optimisation guide (read for this article) documents it under the name size-adjust. A set of @font-face descriptors lets you describe a local fallback font adjusted to the web font's proportions: size-adjust scales the fallback's glyphs, and ascent-override, descent-override and line-gap-override set its vertical metrics. Declared on a rule whose src is a local() system font, they make the fallback occupy almost exactly the space the real font will, so when the swap happens the words change face without changing place. Getting the values takes one measurement pass: render the same string in both fonts, compare the widths and line heights, and set the overrides from the difference. It is fiddly once and free forever after, and it is the difference between a swap readers notice as a flicker and one they notice as the page moving. Combined with the preload plus font-display: optional route from the invisible-text section above, it is also the practical ceiling: the font arrives early, and anything still shown in fallback is already standing in the right space.
One diagnostic note: if the jump happens on every page of the site including ones where the font is certainly cached, suspect this metric mismatch, not loading. Cached fonts swap instantly, so a consistent jump with no network delay is the two fonts disagreeing about size, and no amount of preload tuning will move it.
Symptom: Bold Looks Smeared, Italic Looks Slanted-and-Wrong
The browser is synthesising. When the design calls for weight 700 and the only declared face is 400, browsers fake bold by thickening strokes, and fake italic by slanting the upright face. It reads as "the font looks off" rather than "the font failed". Two causes: the bold file was never declared (only regular was added to @font-face), or the files are declared under weight numbers that do not match how the CSS asks for them, so bold text is matched to the regular face and synthesised from there. The diagnosis is check 7: list the declared faces, list the weights and styles the design actually uses, and reconcile the two lists. Serving a variable font removes the mismatch class entirely, which the variable fonts guide covers, at the price of declaring the weight range correctly in the rule.
Symptom: Some Characters Render in a Different Font
Mid-word font changes mean the web font loaded and then ran out of glyphs. The missing characters fall back individually, so an accented letter, a currency symbol, or an emoji-adjacent character arrives in a visibly different face. The cause is a subset that does not cover the content: somebody subsetted to basic Latin and the page later gained a name with an accent, a price in euros, or a typographic symbol the subset dropped. This is the sharp edge of the subsetting step in the formats guide: subsets are built from assumptions about content, and content changes. The fix is to re-subset against the site's real character set, including the characters in user-generated content and page titles, or to serve the full font where the content is unpredictable. unicode-range declarations create the same symptom on purpose when they are right, and by accident when a range is misdeclared, so check them before re-subsetting.
Symptom: Fine in Chrome, Broken in One Other Browser
Work through the format stack first: a rule that offers only a format the browser cannot read gives it nothing to load, which is why the formats guide recommends WOFF2 as the single modern source, with WOFF behind it if a legacy engine genuinely matters. Then check the MIME type the server sends: browsers apply strict checking when a page or host sets X-Content-Type-Options: nosniff, and a font served as text/html or application/octet-stream can be refused where a lenient setup would have let it through. The correct types are font/woff2 and font/woff for the wrappers. Finally, check for browser-specific caching of failure: a font that 404'd during development can be remembered by an aggressive cache long after the file is fixed, which is what versioned file names (example-sans.2026.woff2, or a hash in the name) exist to defeat.
The Symptom Table
| Symptom | First suspect | Confirm by |
|---|---|---|
| All text in fallback, no font request | Family name mismatch, or the CSS file is not loaded | Network panel shows no font fetch at all |
| Font request returns 404 | URL resolved relative to the CSS file | Opening the requested URL directly |
| Font URL returns an HTML page | Host fallback rewriting asset paths | Content-Type of the response is text/html |
| Works same-origin, blocked from CDN | Missing Access-Control-Allow-Origin | Console CORS error naming the font |
| Text invisible for seconds | font-display unset or block | The @font-face rule's font-display value |
| Bold or italic looks fake | Only one weight or style declared | Compare declared faces with used weights |
| Odd characters in another font | Subset missing glyphs | Test the exact characters against the subset ranges |
| Font downloads twice | Preload without matching crossorigin | Two identical entries in the network panel |
| One browser only | Format support or MIME strictness | That browser's console and the response headers |
Pre-Flight Checklist for a New @font-face Setup
- One family name, spelled identically in the @font-face declaration and everywhere it is used.
- URLs checked from the CSS file's location, and the deployed URL opened by hand once to confirm it returns the font.
- format() hints that describe the real bytes, WOFF2 first.
- font-display set deliberately on every face, not inherited by silence.
- Every weight and style the design uses declared with a real file, no synthesis by accident.
- Cross-origin fonts served with Access-Control-Allow-Origin, and any preload carrying crossorigin.
- Subset ranges written down next to the font files, with the content they were built to cover.
- A hard refresh on a throttled connection watched once, because that is the load your visitors get.
Frequently Asked Questions
Why does my font work locally but not on the live site?
The path, nine times in ten. Local setups forgive relative URLs because the folder layout matches the developer's machine; the deployed layout rarely does, and @font-face resolves URLs from the CSS file's position. The runner-up is the host: a fallback rule or a missing CORS header that does not exist in the local server. Compare the live font request in the network panel with the local one, status, content type, and headers, and the difference is the diagnosis.
Is the flash of the wrong font a bug I can eliminate completely?
You can choose which imperfection you ship. swap guarantees visible text and a visible change when the font arrives. optional, paired with preload, is the documented route to no visible change at all: web.dev describes the combination as removing the layout jank because the font either makes the very short block window or sits the page view out. What you cannot have is a large font, a slow connection, instant text, and no change, all four. Pick the three your readers value.
Why is my bold font file never downloaded?
Because nothing asked for it in a way the browser could match. If headings are styled font-weight: 700 but the bold face is declared under a different family name (a common pattern from font files named "ExampleSans-Bold" loaded as their own family), the browser matches weight 700 inside the regular family, finds only 400, and synthesises. Declare every file under one family name with its true weight and style in the rule, and the browser will fetch exactly the files the design uses.
How do I reproduce a font failure that a user reports but I cannot see?
Make your browser live in the user's conditions. Font failures hide behind fast connections and warm caches, both of which a developer's machine has and a visitor's phone may not. Open a private window or a fresh browser profile so no cached font or stylesheet survives from earlier testing, turn on network throttling in the developer tools to a slow profile, and reload while watching the fonts filter in the network panel. If the user names a specific browser, test in that browser family rather than assuming the behaviour transfers: format support, cache habits and font-display defaults are exactly the things that differ. If the failure still will not reproduce, ask for the page and the moment, whether the text was missing, wrong, or late, because each answer points back to a different one of the eight checks.
Does clearing the cache actually fix font problems?
It fixes exactly one: a stale failure cached during development, and its tidier cousin, an old font file cached under an unversioned name after you replaced it. It fixes nothing about paths, CORS, or declarations. If a hard refresh changes the behaviour, suspect caching and adopt versioned font file names; if it does not, the cause is in the eight checks, and the network panel will show it.
My font loads but looks thinner or lighter than the design file. Why?
Usually the design tool and the browser are rendering different weights of the same family: the mockup used a named style that maps to 500 while the CSS asks for 400, or the variable font in the browser is being asked for an interpolated weight the static mockup could not show. Less often, the served file is a different cut of the family (a "text" version against a "display" version). Compare numbers, not impressions: the weight value in the CSS, the weight the file declares, and the style name in the design file should all agree.
Sources Read for This Guide
- web.dev, Prevent layout shifting and flashes of invisible text (FOIT) by preloading optional fonts: the font-display timeline of block, swap, and failure periods, the behaviour of the five values, and the preload plus optional combination.
- SitePoint, How to Optimize Web Fonts: WOFF2, Subsetting, and Loading Without Layout Shift (2026): the subsetting workflow and the failure modes it introduces, and the size-adjust technique for matching a fallback font's metrics to the web font.
- The CSS font matching and fetching rules as exercised throughout: @font-face URL resolution against the stylesheet, CORS-mode font fetches, and per-character fallback for missing glyphs are the documented behaviours the checks above are built on.
Font tooling and browser behaviour change at the edges; the eight checks are ordered so that the causes that survive those changes, names, paths, headers, and declarations, are eliminated first.