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

How the CSS pipeline rewrites a font URL Authored CSS contains a relative font-face url. The CSS pipeline emits the font with a content hash and rewrites the url value. An absolute publicPath resolves the same from any stylesheet depth, while a relative one breaks when the stylesheet moves. How the CSS pipeline rewrites a font URL Authored CSS font-face src is url(../fonts/inter.woff2) CSS pipeline emits the font as an asset rewrites the url() value Emitted CSS url(/static/fonts/ inter.3f7a91c2.woff2) Absolute publicPath resolves from any CSS depth one URL from every stylesheet Relative publicPath resolves against the CSS file breaks when the CSS moves
The rewrite happens inside the CSS pipeline; what the rewritten value looks like depends entirely on the public path setting.

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-face pointing 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.

Preloading a hashed font with CORS The browser discovers a preload link, fetches the hashed font from the asset host with an Origin header, receives an allow-origin header and an immutable cache directive, and the font-face rule then resolves to the same URL without a second fetch. Preloading a hashed font with CORS Browser Stylesheet Asset host preload link discovered in head GET the hashed woff2, Origin header sent 200 with allow-origin and immutable font-face src resolves to that same URL served from the preload cache, no second fetch A preload without crossorigin fetches the font twice and warms neither request.
The preload and the `@font-face` fetch must agree on URL and CORS mode, or the browser performs two downloads.

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.

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.

One family, four subset files A single font family at one weight is split into Latin, Latin Extended, Cyrillic and Greek subsets by unicode-range; each subset is an independent file with its own content hash and download size. One family, four subset files One family Inter, weight 400 Each subset is its own file with its own hash and cache entry. inter-latin.9c1f4a70.woff2 unicode-range U+0000 to U+00FF, 12 KB inter-latin-ext.4b0d82e1.woff2 unicode-range U+0100 to U+024F, 9 KB inter-cyrillic.7e35c9ab.woff2 unicode-range U+0400 to U+045F, 7 KB inter-greek.02a6df58.woff2 unicode-range U+0370 to U+03FF, 5 KB
Four `@font-face` rules, one family name, four hashed files — the browser downloads only the ranges the page actually uses.

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.