Cache-Control: immutable for Long-Lived Font Files

This guide is part of the Browser Font Caching: HTTP Headers & Invalidation area, within the broader Font Loading & Delivery Strategies section.

Every repeat visit to a site that skips this header pays a tax it doesn't need to pay: the browser already has inter-var-latin.woff2 sitting in disk cache, byte-for-byte identical to what the server would send back, yet it still opens a connection, attaches an If-None-Match header, and waits for a 304 Not Modified round trip before it's allowed to use the file it already has. On a fast connection that's 20–80ms wasted per font per navigation. On a slow mobile network with a cold TCP/TLS handshake to a third-party CDN, it can be 300ms or more, stacked in front of first paint if the font is render-blocking. The fix is a single response header, Cache-Control: immutable, combined with a long max-age, applied only to font files whose URL changes whenever their content changes.

Prerequisites

Before adding immutable to your font responses, two things need to already be true:

  • Content-hashed filenames. The font URL itself must encode a fingerprint of the file's bytes — inter-latin-a1b2c3d4.woff2, not inter-latin.woff2. Build tools (webpack, Vite, esbuild, Next.js) do this automatically for imported assets; if you self-host fonts manually, add the hash yourself during your build step or with a sha256sum snippet in CI.
  • A correct format and subsetting pipeline already in place. immutable caches the exact bytes you serve today for a year, so you want to be confident those bytes are the right ones — see Font Format Strategy for choosing .woff2 as the primary format, and Unicode-Range & Font Subset Loading if you're shipping subsets rather than full character sets.

If your font URLs are NOT fingerprinted, stop here and fix that first — applying immutable to a stable, non-hashed URL like /fonts/inter.woff2 means any future update to that file will be invisible to returning visitors for up to a year, because the browser will never even ask if it changed.

It helps to be precise about what problem this header actually solves, because it's easy to conflate it with unrelated caching concerns. A font file with a long max-age is already considered "fresh" for the life of that max-age — the browser is already allowed to reuse it without contacting the server at all. What immutable changes is a narrower, specific behavior tied to the reload gesture. Historically, browsers treated a user-triggered reload as an implicit request for confirmation that cached content is still current, so even resources with a long max-age would get a conditional If-None-Match / If-Modified-Since request fired on reload, expecting a fast 304 back. For most assets that's a reasonable trade-off — a cheap round trip in exchange for staying current. For a fingerprinted font file, it's pure waste: the URL itself is the version identifier, so there is nothing to confirm. immutable tells the browser to skip that round trip entirely, including on reload, for as long as the resource remains fresh under its max-age.

Implementation

The core config is a static-asset rule scoped to your font file extensions, most commonly deployed at a CDN edge or reverse proxy rather than in application code, since fonts should never touch your origin server on a cache hit.

location ~* \.(woff2|woff)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    add_header Vary "Origin";
    try_files $uri =404;
}

Line by line:

  • location ~* \.(woff2|woff)$ matches any request path ending in .woff2 or .woff, case-insensitively, so it applies regardless of how the rest of your routing is structured.
  • public marks the response cacheable by both the browser and any shared caches (CDN edge nodes, corporate proxies) sitting between the browser and origin.
  • max-age=31536000 is exactly 365 days in seconds — the practical ceiling most caches respect; some CDNs cap effective TTL lower internally even if you send a longer value, so 31536000 is the safe, portable maximum.
  • immutable is the header that actually changes browser behavior beyond max-age: it tells a supporting browser (Firefox, Safari, and Chromium-based browsers since Chrome 76) that it must never send a conditional revalidation request for this resource while it's fresh — not on a hard navigation, not even in response to the user pressing reload. Without immutable, some browsers will still fire a conditional GET on reload despite a long max-age, because reload historically implied "the user wants confirmation this is current."
  • Access-Control-Allow-Origin and Vary: Origin are required if the font is served cross-origin (a CDN subdomain, or any origin different from the page loading it) — browsers refuse to apply an already-downloaded @font-face resource across origins without a valid CORS response, and this is a common cause of fonts silently re-downloading. See Serving Fonts from a CDN with Correct CORS for the full CORS story if you're delivering from a separate host.

The same policy on a platform without native config file access, applied at the application layer:

// Express / Node static font handler
app.use('/fonts', express.static('dist/fonts', {
  maxAge: '365d',
  immutable: true,
  setHeaders(res, path) {
    if (path.endsWith('.woff2') || path.endsWith('.woff')) {
      res.setHeader('Access-Control-Allow-Origin', '*');
    }
  },
}));

express.static's immutable: true option appends the directive automatically once maxAge is set, so you don't need to hand-write the Cache-Control string — but the underlying HTTP result is identical to the nginx config above.

Repeat-Visit Font Request Time Bar chart comparing font request duration on a repeat visit with and without Cache-Control immutable. Repeat-Visit Font Request Time No immutable (304 check) 80ms immutable (disk cache) 0ms ms
Skipping revalidation removes the round trip on every repeat visit.

Defensive variant: guarding against non-hashed fallback requests

A common failure mode is a build pipeline that hashes fonts in production but falls back to an un-hashed path in development, or a CMS-managed font upload path that reuses the same filename across replacements. Applying immutable blindly by file extension will cache a font someone re-uploads under an unchanged name for a year, with no way for existing visitors to see the update short of a full cache-busting change on your end. Guard the rule so immutable only fires on paths that actually contain a hash pattern:

location ~* /fonts/.+-[0-9a-f]{8,}\.(woff2|woff)$ {
    # Fingerprinted assets: cache forever, skip revalidation entirely
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location ~* /fonts/.*\.(woff2|woff)$ {
    # Anything else under /fonts/: still cacheable, but revalidated
    add_header Cache-Control "public, max-age=86400, must-revalidate";
}

The first block only matches filenames containing at least 8 hex characters before the extension (a typical content-hash length), so a manually uploaded brand-logo-font.woff2 falls through to the second block, which caps freshness at a day and forces revalidation after that — a much safer default for anything whose bytes might change under a stable name.

Verification

Confirm the header is present and correctly interpreted in two places: the network response itself, and actual browser cache behavior on a reload.

  1. Open DevTools → Network, reload the page, click the font request, and check the Response Headers panel for cache-control: public, max-age=31536000, immutable.
  2. Reload again (a normal reload, not a hard reload) and confirm the font request now shows "(disk cache)" or "(memory cache)" in the Size column with Status: 200 — not a 304, and no request timing bar at all if it's served purely from cache.
  3. For a scripted check, PerformanceObserver on resource entries lets you assert this in CI or RUM:
new PerformanceObserver((list) => {
  for (const entry of list.getEntriesByType('resource')) {
    if (entry.name.endsWith('.woff2')) {
      // transferSize of 0 (or near-0) with nonzero decodedBodySize
      // means the response came straight from cache, not the network
      console.log(entry.name, entry.transferSize, entry.decodedBodySize);
    }
  }
}).observe({ type: 'resource', buffered: true });

A transferSize of 0 on a repeat load, paired with a nonzero decodedBodySize, confirms the browser served the font entirely from its disk cache with zero bytes on the wire and zero round-trip latency. This same technique underlies the field measurement approach described in Measuring Font Loading Performance.

immutable Support by Browser Comparison table of Cache-Control immutable directive support across major browsers. immutable Support by Browser Chrome Firefox Safari immutable honored 76+ 49+ Yes Skips reload revalidati… Yes Yes Yes Fallback if unsupported max-age max-age max-age
Unsupported browsers ignore the directive and fall back to max-age rules.

Common Pitfalls

  • Applying immutable to non-hashed URLs. If /fonts/inter.woff2 never changes name, immutable means a font update you ship next month won't reach returning visitors for up to a year. Fingerprint the filename first; only then add the header.
  • Forgetting CORS on cross-origin font hosts. A CDN-hosted font with a long-lived Cache-Control but no Access-Control-Allow-Origin header is invisible to @font-face — the browser downloads it, discards it as an opaque cross-origin response, and re-downloads it every single page load regardless of caching headers.
  • Sending immutable without max-age. immutable only modifies revalidation-on-reload behavior; it does not itself make a resource fresh. Without a max-age, some caches treat the response as having an implicit or heuristic freshness lifetime that's far shorter than intended, undermining the whole point.
  • Confusing this with preload. Cache-Control: immutable controls what happens on subsequent visits; it does nothing for the very first request. Pair it with <link rel="preload"> (see Font Preloading & Resource Hints) if you also need to speed up first-visit delivery.
  • Setting max-age lower than the CDN's own edge TTL, or vice versa. If your CDN's edge cache setting overrides or ignores the origin's Cache-Control value, the browser and the edge can disagree about freshness — always verify the header your CDN actually forwards to the client with a real network inspection, not just your origin config.
  • Applying it to the CSS file that declares @font-face, not just the font binaries. If your fonts.css is itself hashed and immutable but the woff2 files it references aren't, you gain nothing; if it's the other way around, an unhashed stylesheet can safely stay short-lived while its referenced hashed fonts stay immutable — that mismatch is fine and expected.

Frequently Asked Questions

Does immutable work in every browser? Firefox and Safari have supported it since its introduction; Chromium-based browsers (Chrome, Edge, Opera) added support in Chrome 76. Browsers that don't recognize the directive simply ignore it and fall back to standard max-age freshness rules — there's no error or breakage, only a missed optimization on very old browser versions, so it's always safe to send.

What happens if I need to roll back a font that was already served with immutable? Because the filename is hashed, a rollback means reverting your build to reference the previous hash — you're never trying to invalidate a specific cached URL, you're simply pointing @font-face back at a different, already-cached (or re-fetched) filename. This is the entire reason fingerprinted URLs and immutable are designed to be used together: cache invalidation becomes a deploy-time filename change instead of a runtime cache-busting problem.

Should I use immutable on woff2 files served through cross-site cache partitioning? Yes — immutable and cache partitioning are unrelated mechanisms and both apply independently. Partitioning affects whether a cached copy can be reused across different top-level sites; immutable affects whether a revalidation request is sent once a copy is deemed usable. See HTTP Cache Partitioning and Cross-Site Font Sharing for how partitioning changes the caching math for fonts shared across sites, including popular CDN-hosted font services.

Does a shorter max-age with immutable still help? Somewhat, but the value diminishes fast. immutable prevents revalidation-on-reload for as long as the resource is considered fresh, so a max-age=86400 (one day) immutable font stops behaving specially after 24 hours and falls back to normal conditional-request handling. Since fingerprinted fonts never actually need to be revalidated at all, there's no downside to the full 31536000-second ceiling — use it.

Will this affect the very first font request for a new visitor? No. Cache-Control: immutable only governs behavior for a client that already has a cached copy of the exact URL — a first-time visitor still pays the full network cost of downloading the font once. That first download is a separate optimization problem, covered by choosing an efficient format (see How WOFF2 Brotli Compression Shrinks Fonts) and by prioritizing the request with resource hints so it doesn't compete with less critical assets on a congested connection.

Do I need immutable if I'm already using a CDN with a long default TTL? Usually yes, because the CDN's edge cache and the visitor's browser cache are two separate layers with two separate sets of rules. A generous edge TTL keeps the CDN's cache warm so it doesn't need to fetch from your origin repeatedly, but it says nothing about whether the browser revalidates on reload — that behavior is controlled entirely by the Cache-Control header the CDN forwards downstream to the client. Check that your CDN passes the origin's header through unmodified, or configure the equivalent directive in the CDN's own response-header rules if it strips or rewrites Cache-Control by default, which several popular CDNs do unless explicitly configured otherwise.

Deploying immutable Font Caching Four-step process for safely rolling out immutable caching on font files. Deploying immutable Font Caching 1 Fingerprint filename build hash 2 Set max-age=31536000 one year 3 Add immutable skip 304s 4 Verify in DevTools disk cache hit
Hash filenames first, then apply the header only to fingerprinted paths.

Related