Hashing Web Fonts Without Breaking @font-face src
Turning on content hashing for fonts fails in one of two ways: every font request returns 404 because the src: url() still names the old file, or the fonts load but every page flashes unstyled text because a preload hint points at a URL that no longer exists. Both are rewriting problems, not caching problems.
A font is the only common asset whose reference lives inside another hashed file. The <img> tag that names an image is in HTML, which is regenerated on every deploy. A @font-face rule lives in a stylesheet that is itself renamed by content hash — so the rewrite has to happen inside the CSS pipeline, before the stylesheet’s own hash is computed, and anything that bypasses that pipeline is left holding a dead URL.
What Has to Be Rewritten, and by Whom
In Webpack the rewrite is performed by css-loader, which parses each url() and turns it into a module request. The font then flows through whichever asset-module rule matches its extension, and the emitted filename is substituted back into the stylesheet. In Vite the same job is done by the built-in CSS plugin, which resolves url() references relative to the stylesheet and registers them as Rollup assets.
Three things bypass this machinery and therefore never get rewritten:
- Absolute URLs.
url(/fonts/inter.woff2)is treated as an already-resolved runtime URL and left alone. Only relative specifiers are resolved as modules. - Fonts in
public/. Nothing there is parsed, so a hand-written@font-facepointing at/fonts/…keeps that literal name forever. - CSS injected at runtime. A stylesheet built as a string in JavaScript and appended to the document is never seen by the build.
If you need to keep an absolute-looking path but still get the rewrite, tell the loader to resolve it. In css-loader, prefix the specifier with ~ for a package resolution, or set url.filter to opt specific absolute paths back into resolution.
Absolute Versus Relative Public Path
| Setting | Emitted url() value |
Works when the stylesheet is nested | Works on a separate asset host | Breaks on | Best for |
|---|---|---|---|---|---|
output.publicPath: '/static/' |
/static/fonts/inter.3f7a91c2.woff2 |
Yes | Only if the host is the same origin | A deploy under a different path prefix | Single-origin sites with a fixed prefix |
output.publicPath: 'auto' |
../fonts/inter.3f7a91c2.woff2 |
Yes, recomputed per stylesheet | Yes, follows the CSS | Stylesheets inlined into HTML at a different depth | Portable widgets and embeds |
output.publicPath: 'https://cdn.example.com/' |
Absolute cross-origin URL | Yes | Yes | Any origin change without a rebuild | A fixed, long-lived asset domain |
generator.publicPath on the font rule only |
Font URL differs from image URLs | Yes | Yes | Mismatched CORS config between hosts | Serving fonts from a dedicated host |
Vite base: '/static/' |
/static/assets/inter.3f7a91c2.woff2 |
Yes | Same-origin only | Multi-tenant deploys under varying prefixes | Most Vite applications |
Vite base: './' |
Relative to the stylesheet | Yes | Yes | Deep client-side routes with no <base> |
Static bundles served from unknown paths |
The rule that avoids most incidents: pick one public path for every asset class and put fonts on it. A generator.publicPath that sends fonts to a different host than images is legitimate, but it doubles your CORS surface — that host now needs its own Access-Control-Allow-Origin policy, and getting it wrong produces a failure that only appears in Firefox and Safari.
// webpack.config.js — fonts emitted, hashed, and referenced absolutely
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
// One prefix for every asset class. Change here, not per rule.
publicPath: '/static/',
filename: 'js/[name].[contenthash:8].js',
clean: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
// Resolve every relative url(); leave data: and absolute URLs alone.
url: { filter: (url) => !url.startsWith('data:') },
importLoaders: 1,
},
},
],
},
{
test: /\.(woff2?|ttf|otf)$/i,
// Never 'asset' — a base64 font blocks the CSS parse and cannot be preloaded.
type: 'asset/resource',
// Change :8 to :12 for very large output trees.
generator: { filename: 'fonts/[name].[contenthash:8][ext]' },
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css' }),
],
};
The Vite equivalent needs no loader wiring — relative url() resolution is built in — but the same “never inline a font” rule applies, which is why the parent guide’s Vite configuration returns false from assetsInlineLimit for font extensions. Webpack’s side is covered in the output hashing setup.
Fonts Are Fetched in CORS Mode
Unlike images, fonts are subject to a same-origin restriction by specification. A font referenced from a stylesheet is fetched with CORS semantics, and the browser will discard the response — after downloading it — if the asset host does not return an Access-Control-Allow-Origin header covering the document’s origin. The download succeeds, the network panel shows a 200, and the text still renders in the fallback face.
The origin configuration is short but must be applied to the hashed font path specifically:
location ~* ^/static/fonts/.+\.[0-9a-f]{8}\.(woff2?|ttf|otf)$ {
# Fonts are CORS-fetched even same-origin when the document and asset
# hosts differ by port or scheme. Allow explicitly.
add_header Access-Control-Allow-Origin "https://example.com" always;
add_header Vary "Origin" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
types { } default_type "font/woff2";
access_log off;
}
Vary: Origin is required whenever Access-Control-Allow-Origin echoes a specific origin rather than * — without it, a shared cache can hand a response allowing origin A to a request from origin B. That is a narrow, legitimate use of Vary; the broader hazards of the header on fingerprinted assets are in Vary header pitfalls. If the asset host serves nothing origin-specific, Access-Control-Allow-Origin: * with no Vary is simpler and caches better.
Preload Links Must Name the Hashed URL
A preload hint is the standard fix for the render delay caused by discovering a font only after the stylesheet parses. It is also the single most common source of dead font URLs, because the <link> lives in HTML while the hash is decided by the bundler.
Never hand-write the preload href. Read it out of the build manifest:
// scripts/font-preloads.mjs — emit preload tags from the build manifest
import { readFileSync } from 'node:fs';
// A manifest mapping source names to emitted, hashed paths.
const manifest = JSON.parse(readFileSync('dist/asset-manifest.json', 'utf8'));
// Only preload faces used above the fold. Preloading everything is worse
// than preloading nothing: it competes with the CSS for bandwidth.
const CRITICAL = ['inter-var.woff2', 'inter-var-italic.woff2'];
export function fontPreloadTags() {
return CRITICAL.map((source) => {
const href = manifest[source];
if (!href) {
throw new Error(`font "${source}" is not in the build manifest`);
}
return (
`<link rel="preload" href="${href}" as="font" ` +
`type="font/woff2" crossorigin="anonymous">`
);
}).join('\n');
}
Three attributes are mandatory and all three are commonly omitted. as="font" sets the correct request destination and priority. type="font/woff2" lets browsers that cannot use the format skip the download entirely. And crossorigin — even for a same-origin font — is required because the @font-face fetch is anonymous-CORS; a preload without it is a different cache entry, so the browser downloads the file twice and the preload buys nothing.
font-display is the other half of the story. swap renders fallback text immediately and swaps when the font arrives, trading a visible reflow for no invisible period. optional gives the font about 100 ms to arrive and otherwise abandons it for that page load, which pairs well with hashed, immutable fonts: the first visit may show the fallback, but the font is then in disk cache forever and every later visit renders it instantly. block is almost never the right answer on a public site.
Subsets Each Carry Their Own Hash
Splitting a family by unicode-range is the highest-leverage font optimisation available: a visitor reading Latin text downloads only the Latin subset. Each subset is a separate file, so each gets its own hash and its own cache entry.
The stylesheet declares the same font-family four times with different ranges. Because each rule names a different hashed file, re-subsetting one range leaves the other three cache entries untouched:
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-latin.woff2') format('woff2');
font-weight: 400;
font-display: optional;
unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+2212;
}
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-cyrillic.woff2') format('woff2');
font-weight: 400;
font-display: optional;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+2116;
}
Preload only the subset your primary audience needs. Preloading all four defeats the purpose — you have paid the full family download to save a parse.
Re-subsetting churn
Subsetting is not deterministic across tool versions. Two runs of a subsetter with different fonttools or woff2 builds produce different bytes for the same input glyph set — different table ordering, different compression parameters — which produces a different hash and a needless re-download for every returning visitor. Treat the subsetter like any other build dependency: pin its exact version, run it in a container, and commit the subset outputs if the family changes rarely. A font family that changes once a year should not be regenerated on every CI run.
The same applies to the glyph coverage you request. Widening a range by one character rewrites the file and invalidates that subset for everyone, so batch coverage changes rather than trickling them out. The upside of subsetting is that the blast radius is one range instead of the whole family, but only if the other ranges genuinely stay byte-identical.
Trim the format stack
A src descriptor with four formats is a legacy habit. Every browser that matters supports WOFF2, and each additional format is another file to hash, upload, and keep in sync. Ship WOFF2 alone unless you have measured traffic that needs otherwise:
@font-face {
font-family: 'Inter Var';
/* One format. The bundler rewrites this to the hashed emitted filename. */
src: url('../fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: optional;
}
A variable font compounds the saving: one hashed file covers every weight, so a weight change in the design system costs zero new downloads. The trade is that the single file is larger, and any edit to it invalidates all weights at once — the same coupling described for inlined assets in the parent guide.
Verification
# Every font URL the emitted CSS actually references, checked end to end.
grep -rhoE 'url\(([^)]*\.woff2?)\)' dist/static/css/*.css \
| sed -E 's/url\(|\)|"|'"'"'//g' \
| sort -u \
| while read -r u; do
curl -sI -H 'Origin: https://example.com' "https://example.com${u}" \
| awk -v u="$u" '
/^HTTP/ { code=$2 }
tolower($0) ~ /^access-control-allow-origin/ { acao="ACAO-ok" }
tolower($0) ~ /immutable/ { imm="immutable" }
END { printf "%-5s %-9s %-10s %s\n",
code, (acao?acao:"NO-ACAO"), (imm?imm:"no-immutable"), u }'
done
Any line showing NO-ACAO is a font that will download and then be discarded by Firefox and Safari. Any 404 means the CSS was emitted with a URL the deploy did not upload — almost always a stylesheet that escaped the pipeline. Cross-check the preload hints separately, because a stale preload does not break rendering and so never shows up in a visual test:
curl -s https://example.com/ \
| grep -oE 'href="[^"]*\.woff2?"' \
| cut -d'"' -f2 \
| while read -r u; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com${u}")" "$u"
done
When to Reconsider
Keep fonts in public/ with fixed names when a stable font URL is a contractual requirement — a design system consumed by teams you do not control, or an email template that references the file. Accept a short TTL and version the directory by hand when the file changes.
Skip preloading when the page uses font-display: optional and the font is not part of the first paint. The preload competes with the stylesheet and the LCP image for early bandwidth, and optional means the first visit is going to use the fallback anyway.
Skip subsetting when the family is already under about 20 KB compressed. Four subset files mean four connections, four cache entries, and four things to keep in sync for a saving smaller than a single hero image.
Related
- Hashing images, fonts, and media — parent guide on which assets the bundler hashes
- Responsive image srcsets — the same fan-out problem for image variants
- Video and audio cache busting — assets that never enter the bundler at all
- Webpack output hashing setup — where
publicPathand asset-module naming are configured - Cache-Control immutable and TTL tuning — TTL choices for font files on a shared asset host