Immutable and Versioned Asset Caching
TL;DR: The immutable Cache-Control extension tells a browser that a cached response will never change for the lifetime of its freshness window, so an explicit user reload should not trigger a conditional request at all. It is only safe to use on URLs whose content is fully determined by a content hash or version identifier embedded in the path — because once that content changes, the build produces a new URL rather than mutating the old one.
Quick-reference header block for a content-hashed JavaScript bundle:
Cache-Control: public, max-age=31536000, immutable
Mechanism and RFC Alignment
Standard HTTP caching, as defined by RFC 9111, distinguishes between a fresh response (servable without contacting the origin) and a stale one (requiring conditional revalidation via ETag/If-None-Match or Last-Modified/If-Modified-Since). What RFC 9111 does not address is a separate, long-standing browser behavior: when a user presses reload (as opposed to navigating forward, following a link, or restoring from history), most browsers treat every subresource on the page as conditionally revalidated regardless of remaining max-age. That reload-triggered revalidation is deliberate — it lets a stuck page recover from a bad cached copy — but it also means that even a resource cached with max-age=31536000 generates an If-None-Match round trip on every reload, at the cost of one round-trip latency per asset even when the server ultimately answers 304 Not Modified.
immutable was defined in RFC 8246 specifically to suppress that reload-triggered check. When a browser that implements the extension sees Cache-Control: immutable on a still-fresh response, it skips the conditional request entirely on reload and serves the cached body directly, exactly as it would for a normal in-window navigation. The directive changes nothing about the shared-cache tier: CDNs and reverse proxies key their storage on max-age or s-maxage precedence exactly as before, and simply pass immutable through as an opaque token if they don’t specifically parse it.
The RFC 8246 contract has one hard precondition that the specification states explicitly: immutable only has meaning attached to a response that is also fresh (governed by max-age or Expires), and it must never be applied to a resource whose body can change while the URL stays the same. This is why the extension is inseparable from a fingerprinting build pipeline — the directive is a promise about the URL, not about the underlying logical asset. app.js can change; app.3f9a21c.js cannot, because a change to its content produces a different hash and therefore a different URL.
Why content-hashed URLs make the promise safe
A build tool (Vite, webpack, esbuild, Rollup) computes a hash — typically a truncated SHA-256 or MD5 digest — of a bundle’s post-processed output and embeds it in the emitted filename, for example main.a1b2c3d4.js. Any change to the source, however small, changes the compiled output bytes and therefore the hash, which produces a new filename and a new URL. The old URL is never reused and never mutated. Because the URL-to-content mapping is now permanent, the year-long max-age=31536000 (the maximum value RFC 9111 permits caches to honor, expressed in seconds — 365 days) is not a gamble on the content staying stable; it is guaranteed by construction. immutable simply lets a supporting browser act on that guarantee even on reload.
Diagram: Versioned Asset Deploy Flow
Scope and Precedence
immutable operates exclusively at the browser (private cache) tier. It has no defined effect on shared caches:
| Cache tier | Governing directive | Effect of immutable |
|---|---|---|
| Browser private cache | max-age |
Suppresses reload-triggered revalidation while fresh (supporting browsers only) |
| CDN / reverse proxy (shared cache) | s-maxage (falls back to max-age if absent) |
None — treated as an unrecognized token, TTL storage unaffected |
| Origin shield / mid-tier cache | s-maxage |
None |
Because CDNs don’t need to understand immutable to serve these assets correctly, the directive is safe to add to any public response without coordinating a CDN configuration change. What does matter for the shared-cache tier is that the response is marked public — a hashed asset served with private would still be excluded from shared-cache storage regardless of immutable or max-age.
immutable does not override or interact with no-cache, no-store, or must-revalidate — those directives take precedence if present, because they impose stronger constraints than a freshness hint can relax. A response that (incorrectly) combines no-cache with immutable still forces revalidation on every use; immutable cannot un-require what no-cache requires. This is a special case of the general directive-stacking precedence rules that govern how conflicting directives on the same response resolve.
Implementation Patterns
Pattern 1 — Hashed JavaScript/CSS bundle (the canonical case)
GET /assets/main.a1b2c3d4.js HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
The filename encodes the content hash. There is no scenario in which this exact URL serves different bytes, so the one-year max-age plus immutable combination is fully safe.
Pattern 2 — Hashed images and fonts
HTTP/1.1 200 OK
Content-Type: font/woff2
Cache-Control: public, max-age=31536000, immutable
Access-Control-Allow-Origin: *
Web fonts served cross-origin need Access-Control-Allow-Origin alongside the caching headers, or browsers will refuse to apply them even once cached. The caching directives themselves are identical to the JS/CSS case.
Pattern 3 — The entry HTML document (negative example — never immutable)
GET /index.html HTTP/1.1
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: no-cache
The HTML document that references the hashed asset URLs is the one file in the deployment that is not content-addressed — its URL (/ or /index.html) stays constant across every deploy while its body (the list of hashed asset filenames) changes. Applying max-age or immutable here would pin users to a stale manifest that references assets already deleted from the CDN. no-cache (revalidate on every request, cheap because HTML is small) is the correct baseline; see no-cache vs no-store for the full distinction between the two.
Pattern 4 — Versioned download artifacts (path-segment versioning instead of hash)
GET /downloads/v2.4.1/cli-linux-amd64.tar.gz HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/gzip
Cache-Control: public, max-age=31536000, immutable
Not every immutable-eligible resource uses a content hash — a semantic version segment in the path works identically, provided the build process guarantees a given version number is never re-published with different bytes. This is common for release artifacts, SDKs, and package registries.
Pattern 5 — Query-string versioning (fragile, avoid if possible)
GET /assets/main.js?v=8827 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
Query-string cache busting works at the browser tier but is risky at the CDN tier: some CDN configurations normalize or strip query strings from the cache key by default, which would collapse every version onto one shared cache entry. Path-embedded hashes are the safer default because they cannot be stripped by a cache-key normalization rule.
Server and CDN Configuration
Nginx
# Hashed build output — filenames contain a content hash or version segment
location ~* ^/assets/.+\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# The HTML entry point must revalidate on every request
location = /index.html {
add_header Cache-Control "no-cache" always;
}
The regex matches only filenames containing a hex hash segment of eight or more characters, preventing the immutable rule from accidentally matching un-hashed static files served from the same directory.
Apache
Header always set Cache-Control "public, max-age=31536000, immutable"
Header always set Cache-Control "no-cache"
Cloudflare — Cache Rules
Cloudflare’s edge does not need to interpret immutable at all; it stores based on max-age/s-maxage and your Cache Rules configuration. Ensure the origin sets the header and that no Cloudflare Transform Rule strips it before reaching the client:
Cache-Control: public, max-age=31536000, immutable
CF-Cache-Status: HIT
If you want Cloudflare itself to enforce a long edge TTL independent of the origin header (useful when the origin is misconfigured), add an Edge Cache TTL override in a Cache Rule scoped to the hashed asset path pattern (/assets/*), separate from the Cache-Control header the browser receives.
Interaction with Related Directives
max-age and s-maxage — immutable is meaningless without a freshness window to apply within; it never replaces max-age. Once max-age expires, the response is stale and immutable no longer suppresses anything — a fresh conditional request (or full fetch) happens exactly as it would without the directive. The one-year value is a convention, not a requirement of RFC 8246; it is simply long enough to be functionally permanent for a hashed URL.
public/private scope — immutable has no effect on a private response beyond what private already implies for the browser’s own cache. It only becomes relevant in combination with public, since shared-cache eligibility and browser reload behavior are governed by different mechanisms; see public vs private cache scope for the storage-eligibility rules that immutable layers on top of.
ETag and validators — hashed assets typically still carry an ETag, most commonly derived from the same content hash used in the filename. This is redundant by design: the URL already encodes the hash, so the ETag is rarely exercised in practice for a supporting browser, but it remains the fallback validator once max-age expires or for browsers (Chrome, Chromium derivatives) that don’t implement immutable’s reload suppression.
stale-while-revalidate — the two directives address opposite problems and are never combined on the same response. stale-while-revalidate exists to serve a slightly outdated copy of a resource that does change, while a background fetch retrieves the update. immutable exists to declare that a resource fundamentally cannot change at that URL. Applying stale-while-revalidate to a hashed asset adds meaningless overhead — there is never a newer version to revalidate toward at the same URL.
Header stacking — because immutable is a bare directive with no value, it composes without ambiguity in stacked or merged header scenarios; the only stacking risk is a proxy or CDN layer re-emitting a conflicting Cache-Control for the same path (for example, a default no-cache rule matching /assets/ before the hashed-filename rule matches). Order location/rule blocks so hashed-path rules are evaluated first, and verify with the workflow below that only one Cache-Control value reaches the client.
Verification Workflow
Step 1 — Confirm headers on a hashed asset
curl -sI https://example.com/assets/main.a1b2c3d4.js \
| grep -iE 'cache-control|etag|last-modified'
Expect Cache-Control: public, max-age=31536000, immutable and an ETag present as a fallback validator.
Step 2 — Confirm the entry HTML is NOT immutable
curl -sI https://example.com/ | grep -i cache-control
Expect Cache-Control: no-cache (or a short max-age). If this response also shows immutable or a long max-age, deployed manifests will reference stale asset URLs after the next release — fix the server rule before shipping further changes.
Step 3 — Verify no revalidation request fires on reload (DevTools)
- Load the page with DevTools Network tab open and “Disable cache” unchecked.
- Note the
Sizecolumn value formain.a1b2c3d4.js— it should show(disk cache)or(memory cache). - Press a hard reload is the wrong test (that bypasses cache deliberately); instead use a normal reload (Ctrl/Cmd+R).
- In a supporting browser (Firefox, Safari), the request for the hashed asset should not appear as a new network request at all when served from the cache; in Chrome, it will still appear but the response should show
200 (from disk cache)rather than304, confirming no round trip to origin occurred for validation purposes.
Step 4 — Confirm cache-key stability across a deploy
# Before deploy — record the hash
curl -sI https://example.com/ | grep -o 'main\.[0-9a-f]*\.js'
# After deploy — the old hash's URL should still resolve while pointing at CDN/origin storage
curl -sI https://example.com/assets/main.OLD_HASH.js | grep -i cache-control
A well-configured deploy pipeline keeps prior-version hashed assets available for at least as long as any client might have a stale HTML manifest referencing them — commonly the previous release’s full max-age window.
Failure Modes and Gotchas
-
Applying
immutableto a non-hashed, mutable URL — the most severe failure. If/assets/main.js(no hash) is served withimmutable, supporting browsers will never see updates for up to a year. Audit everyCache-Controlrule to confirm it only matches filenames containing a genuine content hash or version segment. -
The HTML entry point inheriting the immutable rule by accident — a broad Nginx
location /assets/block can accidentally also match/assets/manifest.jsonor other non-hashed manifests referenced by client code. Scope rules with a hash-pattern regex, not a directory prefix alone. -
Chrome ignoring the directive entirely — teams sometimes conclude
immutable“isn’t working” because Chrome’s Network tab still shows a request on reload. This is expected: Chrome still issues the request but should receive a fast200 (from disk cache)without contacting the origin, distinct from a304round trip. The benefit on Chrome is smaller (no origin round-trip savings on reload) but not zero. -
CDN stripping the header — some CDN configurations strip “unrecognized”
Cache-Controlextension tokens as a normalization step. Verify the exact byte content of the header as observed by the client, not just what the origin emits. -
Retiring old hashed assets too early — because HTML documents are cached briefly but not zero seconds, and because CDN edge nodes may still serve a cached HTML response referencing an older hash for the remainder of its TTL, deleting a previous deploy’s hashed assets immediately after a new deploy causes 404s for users mid-transition. Retain at least the previous one or two releases’ asset sets.
-
Confusing
immutablewith cache-busting —immutabledoes not create the versioning; it only lets a supporting browser trust versioning that already exists. Teams sometimes addimmutableto a URL and assume it alone prevents stale content, without first setting up content hashing in the build. -
Long
max-agewithoutimmutablebehaving fine already — because the reload-revalidation behaviorimmutablesuppresses is browser-specific and, in Chrome’s case, cheap (a304is typically fast), some teams skip the directive entirely on lower-traffic sites and see negligible difference.immutableis a genuine optimization at scale (every asset, every reload, across millions of sessions) more than a correctness requirement.
Frequently Asked Questions
Does every browser honor the immutable directive?
Firefox and Safari suppress reload-triggered revalidation when they see immutable on a fresh response. Chrome and other Chromium-based browsers have never implemented that specific suppression — they still issue a conditional request on reload regardless of immutable. Because the directive is additive, browsers that ignore it simply behave as if only max-age were present, which is already correct for a content-hashed URL.
Is immutable part of RFC 9111?
No. It comes from RFC 8246, a small, separate specification published in 2017 that registers the extension directive in the shared HTTP Cache Directive Registry that RFC 9111 references. RFC 9111 defines the core directives; immutable is layered on top as an extension.
Do CDNs need to understand immutable to cache these assets correctly?
No. CDN and reverse-proxy storage decisions are driven by max-age/s-maxage and the resource’s cache key, not by immutable. A CDN that treats the token as opaque still caches and serves the asset for the full TTL — immutable only changes browser reload behavior.
What happens if I apply immutable to a URL that isn’t actually content-hashed?
Updates to that resource will not reach already-cached clients until the full max-age=31536000 window expires in supporting browsers, because the browser has been told not to check. This is why the directive must be scoped exclusively to URLs whose content is fixed by construction — a hash or version segment in the path.
Related
- Freshness vs validation models explained — the broader distinction between serving a fresh response outright and revalidating a stale one, which
immutableshort-circuits on reload. - When does a browser invalidate a cached resource? — covers the browser-side eviction and revalidation triggers that
immutableis designed to bypass for hashed assets. - How to combine Cache-Control directives safely — general rules for combining multiple directives on one response without producing contradictory caching behavior.
- Cache Validation & Conditional Requests — the full reference for
ETag,Last-Modified, and conditional request mechanics that hashed assets fall back to onceimmutableno longer applies.