Browser Font Caching Mechanics: HTTP Headers, Partitioning & Invalidation

Browser font caching dictates repeat-visit performance and layout stability. This guide maps the HTTP caching pipeline, origin partitioning rules, and invalidation workflows required for deterministic font delivery. For broader delivery architecture, reference Font Loading & Delivery Strategies. Implementing these controls reduces repeat-visit font TTFB to near zero and eliminates Cumulative Layout Shift (CLS) caused by late-loading glyphs.

The Problem: Fonts That Re-Download on Every Visit

The failure this guide addresses is a font that is downloaded again on a repeat visit when it should have been read from disk in zero milliseconds. The symptom is repeat-visit flash of fallback text, a font request in the waterfall that should not be there, and a small but avoidable CLS spike when the late glyph swaps in. The root cause is almost always a missing or wrong caching header, a query-string "version" the CDN ignores, or a CORS configuration that forces the browser to re-fetch.

Start the diagnosis in Chrome DevTools. Open the Network panel, filter by Font, then reload the page a second time (the first visit is expected to download). Look at the Size column for each WOFF2 request:

  • (disk cache) or (memory cache) — correct: the font was served locally with zero network cost.
  • A byte count with a 200 status — wrong: the file was downloaded again. The cache is not working.
  • A 304 Not Modified — partially wrong: the browser still paid a round-trip to revalidate. You want immutable to skip even that.

The threshold to hit is simple: on the second navigation, zero font bytes over the network and a repeat-visit font TTFB at or near 0ms. If you see anything else, one of the configuration steps below is missing.

HTTP cache partitioning across two top-level sites The same CDN font URL is stored in two separate cache partitions, one per top-level site, so a hit on site A does not satisfy a request from site B. CDN origin inter-a3f2.woff2 site-a.com partition cached copy HIT — first visit paid site-b.com partition separate copy MISS — pays again
Since cache partitioning by top-level site, one CDN font URL occupies an isolated partition per site — there is no shared cross-site reuse.

Baseline Configuration

Before any optimisation, three things must be true: the font filename carries a content hash, the response sets a long-lived immutable Cache-Control, and cross-origin fetches carry the right CORS headers. This is the minimum correct setup; everything else in this guide tunes around it.

Minimum correct cache headers for a content-hashed font

location ~* \.(woff2|woff)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";   # only needed for cross-origin font hosts
    types { font/woff2 woff2; font/woff woff; }   # correct MIME type
}

The @font-face must point at the hashed filename so a new hash on deploy is a new URL, and therefore a new cache key:

Content-hashed @font-face reference

/* Build output: inter-a3f2b1.woff2 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-a3f2b1.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

With that baseline in place, reload twice and confirm the Size column reads (disk cache) on the second navigation. If it does, the rest of this guide is about hardening the edges — partitioning, CORS, Service Workers, and invalidation.

Cache-Control Directives & TTL Configuration

Set Cache-Control: public, max-age=31536000, immutable for versioned font assets. The immutable directive tells the browser to bypass revalidation checks entirely for the asset's lifetime — no conditional If-None-Match request is sent even if the user forces a reload. Without immutable, a forced reload (or some browsers' navigation heuristics) sends a conditional request and you pay a full round-trip for a 304 Not Modified — the body is not re-downloaded, but the latency still delays render. Verify: in the Network panel, a second navigation shows (disk cache), not 304.

The max-age=31536000 value is one year in seconds, the maximum value the HTTP spec meaningfully respects. This is only safe because the filename is content-hashed: when the font's bytes change, the hash changes, the URL changes, and the old cache entry is simply never requested again. The hash is your invalidation mechanism — you never need to shorten the TTL.

Apply stale-while-revalidate=86400 to allow background cache updates without blocking render. The browser serves the cached font immediately while fetching a fresh copy in the background. Note: stale-while-revalidate and immutable target different caching scenarios — use immutable for content-hashed assets and stale-while-revalidate for assets that may change but tolerate a stale window. For fonts specifically, prefer immutable plus content hashing; stale-while-revalidate is the right tool only when you cannot hash the filename and must serve a stable URL that occasionally changes.

Coordinate initial fetch priority with Preloading & Resource Hints to bypass cache lookup latency on first visit. Caching only helps the second visit; on the first visit the font is a cache miss by definition, so the only lever you have is to discover and fetch it as early as possible.

Avoid no-cache on static font files. Reserve no-cache exclusively for dynamic responses where freshness must be validated per request. A common misconfiguration is a blanket Cache-Control: no-cache from a framework's default static handler — it forces a revalidation round-trip on every single navigation and silently destroys repeat-visit performance for every font on the site.

Cross-Origin Cache Partitioning & CORS

Modern browsers isolate font caches by top-level site and frame origin to prevent cross-site fingerprinting. A font cached by site-a.com is not reused when site-b.com requests the same URL — each origin maintains a separate cache partition. The full mechanics and its impact on shared-CDN strategies are covered in HTTP cache partitioning and cross-site fonts.

Missing Access-Control-Allow-Origin on the font response triggers a CORS error, preventing the font from loading entirely for cross-origin fetches. This is distinct from cache partitioning: CORS is required for any cross-origin font fetch regardless of caching.

CDN edge configurations must forward the Origin request header and include Vary: Origin in the response. This ensures the CDN caches separate response variants per requesting origin, preventing CORS headers from being stripped. Because partitioning erases the old cross-site reuse benefit, the cache math often favors self-hosting over Google Fonts where you control headers end to end.

Validate cache hit ratios via X-Cache or CF-Cache-Status response headers in the network waterfall. A consistent HIT status confirms fonts are being served from edge cache.

There are two distinct cache layers, and they fail in different ways. The browser disk cache is partitioned per top-level site and controlled entirely by Cache-Control; it is what gives you the zero-network repeat visit. The CDN edge cache sits in front of your origin and is shared across all visitors; it is what keeps the first-visit TTFB low. A HIT on CF-Cache-Status means the edge served the file without hitting your origin — good for first visits — while (disk cache) in the browser means no network at all. You want both: a warm edge for new visitors, a warm disk cache for returning ones.

Step-by-Step Caching Hardening Workflow

Each step ends with a verification check so you never ship a cache configuration you have not confirmed.

Diagnostic Steps:

  1. Confirm content-hashed filenames. Inspect the built font URLs — they must contain a hash segment such as inter-a3f2b1.woff2, not inter.woff2?v=2. Verify: changing one glyph and rebuilding produces a different filename, and the old URL is never referenced by the new CSS.
  2. Set the immutable header. Apply Cache-Control: public, max-age=31536000, immutable to the font location block. Verify: the response header appears verbatim in the Network panel's Headers tab for a WOFF2 request.
  3. Reload twice and read the Size column. The first navigation downloads; the second must not. Verify: the second navigation shows (disk cache) and the Size column reports a cached read, not a byte count with status 200 or 304.
  4. Check CORS on cross-origin font hosts. If fonts live on a separate hostname, confirm Access-Control-Allow-Origin and, on the CDN, Vary: Origin. Verify: there is no CORS error in the console and the font renders; the response carries both headers.
  5. Validate edge cache status. On a CDN, read CF-Cache-Status (or X-Cache). Verify: repeated requests from a cold browser show HIT, confirming the edge serves the file without round-tripping to origin.
  6. Test invalidation on deploy. Ship a font change and confirm the new hash propagates. Verify: the new navigation requests the new hashed URL once (a cache miss is expected for the new file), and no page references a stale hash that returns 404.
  7. Confirm a Service Worker, if present, falls back to network. Verify: with the SW active, deleting the CacheStorage entry still renders the font because the fetch() fallback fires.

Browser Compatibility & Caching Matrix

Capability Chrome/Edge Firefox Safari
HTTP cache partitioning (per top-level site) 86+ 85+ Yes (early)
Cache-Control: immutable Honored 49+ Honored
stale-while-revalidate 75+ Behind flag / partial Not supported
Service Worker + Cache API 40+ 44+ 11.1+
navigator.storage.estimate() 61+ 51+ 17+
CORS for crossorigin fonts Yes Yes Yes

Two edge cases matter. Safari does not honor stale-while-revalidate, so on Safari the directive simply degrades to a normal cache that revalidates on expiry — which is why content-hashed immutable assets are the portable choice. And navigator.storage.estimate() arrived late in Safari (17), so any LRU eviction logic in a Service Worker must feature-detect it before relying on quota numbers.

Service Worker & Cache API Pipeline

For most sites the HTTP disk cache with immutable headers is sufficient and a Service Worker adds no value for fonts. A Service Worker cache earns its complexity only when you need fonts available offline, want to pre-warm a subset before first paint on a return visit, or must guarantee availability independent of the browser's HTTP cache eviction. CacheStorage is a separate store that the Service Worker controls explicitly, and it sits in front of the HTTP cache: a fetch event handler intercepts the request before it ever reaches the disk cache.

Implement a cache-first strategy for font files with explicit network fallback. This prioritises local storage while preserving update capabilities. The cache-first shape matters because fonts are immutable once hashed — there is no freshness concern, so serving the cached copy and only hitting the network on a miss is exactly right.

Use cache.addAll() during the install event for critical subset bundles. Pre-caching guarantees immediate availability before the first paint on the next navigation. Restrict this to the one or two above-the-fold weights; pre-caching the entire family wastes quota and download budget on weights that may never render.

Monitor CacheStorage quota limits via navigator.storage.estimate(). Implement an LRU eviction policy when approaching quota limits to prevent storage exhaustion. Because navigator.storage.estimate() only reached Safari in version 17, feature-detect it (if (navigator.storage?.estimate)) before relying on the returned usage/quota numbers, and treat its absence as "do not run custom eviction" rather than failing closed.

Crucially, version the cache name (v1-fonts, v2-fonts) so a Service Worker update can delete the old store in its activate event. Without versioning, a font that changed its content hash leaves the previous bytes stranded in CacheStorage forever, slowly leaking quota across deploys.

Nginx Cache Headers for Font Assets

location ~* \.(woff2|woff|ttf|otf)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    expires 1y;
}

Service Worker Cache-First Strategy

self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'font') {
    event.respondWith(
      caches.match(event.request).then(cached => {
        if (cached) return cached;
        return fetch(event.request).then(response => {
          return caches.open('v1-fonts').then(cache => {
            cache.put(event.request, response.clone());
            return response;
          });
        });
      })
    );
  }
});

Content-Hashed @font-face Reference

/* Build output: inter-a3f2b1.woff2 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-a3f2b1.woff2') format('woff2');
  font-display: swap;
}

Cache Invalidation & Versioning Workflows

Deploy content-hashed filenames (e.g., inter-a3f2b1.woff2) rather than query parameters. Hash-based URLs guarantee unique cache keys for every asset revision.

Query strings (?v=2) are frequently ignored or normalised away by proxy caches and CDNs. Relying on them causes stale asset delivery and unpredictable invalidation behaviour.

Align cache miss fallback rendering with Font-Display Values Explained to prevent FOIT during version transitions.

Automate cache-busting via build pipeline hooks that update CSS references atomically. Synchronised CSS and asset deployment prevents mixed-version rendering where the old CSS references a hash that no longer exists. The dangerous window is a deploy that ships new CSS pointing at inter-NEWHASH.woff2 before the new font file is live on the edge, or that purges the old font while a cached HTML page still references the old CSS. Bundlers like Vite, webpack, and esbuild emit the hash and rewrite the url() in the same build pass, which closes this window automatically — the rewritten CSS and the hashed asset always ship together. If you hash fonts by hand, deploy the new font file first, then the CSS that references it, never the reverse.

Because the hash makes the URL unique forever, you never issue a manual CDN purge for fonts. A purge is only needed when a URL must serve different bytes than before — which content hashing eliminates by design. If you find yourself purging font URLs on every deploy, the filenames are not actually hashed.

Measuring Cache Effectiveness

Caching wins are only real if you can prove them, and every metric you need is in the browser. Capture three numbers on a repeat visit.

  • Repeat-visit font bytes over the network. Filter the Network panel to Font, reload twice, and sum the transferred bytes on the second navigation. The target is zero — every WOFF2 should read (disk cache).
  • Repeat-visit font TTFB. Hover each font request and read the timing. A disk-cache hit reports near-0ms; any measurable TTFB means the request reached the network and your headers are wrong.
  • Edge cache hit ratio. On a CDN, watch CF-Cache-Status/X-Cache across cold-browser loads. A steady HIT means new visitors are served from the edge rather than your origin, which protects first-visit TTFB even though it does nothing for the cached repeat visit.

A correctly cached site shows the first navigation downloading each subset once, then every subsequent navigation reading every font from disk with no network activity at all. If a font reappears in the waterfall on the second load, work back through the hardening workflow — almost always it is a query string, a missing immutable, or a Vary header fragmenting the key.

Common Pitfalls

  • Query String Cache Busting: Root cause: many proxy and CDN caches normalise or drop query strings, so ?v=2 is treated as the same key as ?v=1 and a stale body is served. Fix: switch to path-based content hashing so every revision is a distinct URL.
  • Missing Vary: Origin: Root cause: a shared CDN cache entry that omits Vary: Origin stores one response variant and replays its CORS headers to every requesting origin, so a later origin gets the wrong (or stripped) Access-Control-Allow-Origin. Fix: forward the Origin request header and set Vary: Origin so the CDN caches a variant per origin.
  • Expecting cross-site cache reuse: Root cause: assuming a font on a shared CDN is reused across sites — partitioning ended that in 2020. Fix: stop optimising for a shared cache; control your own headers and, where possible, self-host the fonts so caching is fully yours.
  • Low max-age on Variable Fonts: Root cause: short TTLs force frequent re-downloads of a large variable file, wasting bandwidth and reintroducing swap-driven layout shift. Fix: content-hash the file and enforce 1-year immutable headers.
  • Unprotected Service Worker Cache: Root cause: a cache-first handler with no network fallback returns nothing when the CacheStorage entry is evicted or corrupted, leaving text permanently unstyled. Fix: always chain fetch() after caches.match() and version the cache name for clean upgrades.
  • Misaligned font-display: Root cause: during a version transition the new hash is briefly a cache miss, and a font-display: auto face blocks text for up to 3s. Fix: set font-display: swap or optional so the miss window shows fallback text instead of invisible text.

Frequently Asked Questions

What is the optimal Cache-Control TTL for web fonts? 1 year (max-age=31536000) with immutable for content-hashed assets. The hash in the filename is your invalidation mechanism — when the font changes, deploy a new hash and update the CSS reference.

How does cross-origin cache partitioning impact font delivery? Browsers partition the HTTP cache per top-level site. A font loaded from a shared CDN is not reused across different sites. Each site pays the full download cost on first visit. CORS headers must be correct regardless of partitioning.

Should query strings be used for font cache busting? No. Most CDNs and proxy caches normalise or strip query strings. Use content-hashed filenames in the URL path for reliable invalidation.

How do Service Workers interact with browser font caches? Service Workers intercept fetch requests before they reach the HTTP cache. CacheStorage operates independently of the browser's HTTP cache. Implement cache-first with a network fallback, and version your cache name to allow clean upgrades during Service Worker updates.

Why does my font still re-download even though I set max-age to a year? The most common causes are a query-string version (?v=) that the CDN normalises away, a missing immutable so a forced reload sends a 304 revalidation, or a Vary header that fragments the cache so the browser never matches the stored entry. Check the Network panel: a 200 with bytes means no cache match at all, a 304 means revalidation is firing, and (disk cache) is the target. Confirm the URL is content-hashed and the Cache-Control header reads public, max-age=31536000, immutable verbatim.

Does the browser cache a font separately per page, or per site? Per top-level site, not per page. All pages under https://example.com share one cache partition, so a font fetched on the homepage is reused on every other page of the same site. The partition boundary is the top-level site (plus frame origin for embedded contexts), which is why an identical font on a shared CDN is not shared across two different sites since partitioning shipped in 2020.

Related