Cache-Control Deep Dive for Fingerprinted Assets
Cache-Control headers are the primary contract between your origin, every CDN edge node in the path, and every browser that loads your site — and a single misconfigured directive on a fingerprinted asset silently causes either perpetual revalidation traffic you could eliminate, or stale bytes that survive purge events because the CDN was never told it owned the TTL.
When to Use This Approach vs Alternatives
Cache-Control tuning applies when you control the server or CDN layer that sets response headers. If you cannot set headers — for instance, assets served from a third-party storage bucket with a locked policy — you are working with ETags and conditional requests instead, which is a slower fallback.
Choose header-level Cache-Control tuning when:
- You serve fingerprinted assets (content-hashed filenames) from your own origin or a CDN you configure.
- You want zero conditional requests (no
If-None-Match/If-Modified-Sinceround trips) for assets that will never change at a given URL. - You need to distinguish CDN TTL from browser TTL using
s-maxage. - You are operating at a scale where revalidation volume is a measurable cost line.
Do not use immutable alone as a substitute for content hashing. Immutable tells the browser not to recheck; if you later serve different bytes at the same URL the browser will never find out. The combination of hashed filenames plus immutable is what makes indefinite TTLs safe.
Prerequisites
| Requirement | Minimum version / note |
|---|---|
| Webpack | 5.x — output.filename: '[name].[contenthash:8].js' |
| Vite | 5.x — build.rollupOptions.output.entryFileNames with [hash] |
| Rollup | 4.x — output.entryFileNames: '[name]-[hash].js' |
| esbuild | 0.20+ — --entry-names=[name]-[hash] |
| Next.js | 14 — built-in content hashing, configure via next.config.js headers |
| Astro | 4 — build.assets directory with hashed filenames by default |
| Nginx | 1.18+ for add_header with always flag |
| Cloudflare Workers | Any — Response constructor supports arbitrary headers |
Hash length in examples below is 8 hex characters. For monorepos or projects generating thousands of chunks, 12–16 characters reduces the statistical probability of collision across the full asset graph.
Directive Reference Table
| Directive | Meaning | Applies To | Recommended Value |
|---|---|---|---|
max-age |
Seconds the response is considered fresh by browsers and shared caches | All | 31536000 for hashed assets; 0 or omit for HTML |
s-maxage |
Overrides max-age for shared (CDN) caches only |
CDN edge nodes | 31536000 for assets; 60–300 for HTML |
immutable |
Tells the browser the resource at this URL will never change; skips conditional revalidation entirely during the max-age window |
Browser | Include for all hashed assets |
public |
Allows any shared cache (CDN, proxy) to store the response | Hashed assets, public pages | Required alongside max-age for CDN caching |
private |
Restricts caching to the individual browser; shared caches must not store | Authenticated responses, personalised pages | Use for user-specific content |
no-cache |
Must revalidate with the origin before serving from cache (permits storage) | HTML entry points | HTML index.html, manifests |
no-store |
Do not store the response anywhere | Sensitive data (logout, session tokens) | POST responses containing secrets |
must-revalidate |
Once stale, the cache must not serve the response without revalidating | Any cached resource that must not go stale | Usually redundant when max-age=31536000 |
proxy-revalidate |
Like must-revalidate but applies only to shared caches |
Shared proxies in enterprise networks | Rarely needed with modern CDNs |
stale-while-revalidate |
Serve stale content while fetching a fresh copy in the background | HTML, API responses, short-TTL assets | 60–3600 depending on update frequency |
stale-if-error |
Serve stale content if the origin returns a 5xx or network error | All | 86400–604800 for resilience |
Step-by-Step Implementation
1. Establish the Canonical Split
Every deployment should produce two categories of response with different Cache-Control policies.
Hashed assets — JavaScript bundles, CSS files, images, fonts — have a URL that encodes the content hash. The URL /assets/main.a3f8c1d2.js will never serve different bytes; if the code changes, the build produces /assets/main.9b4e7f12.js instead. This makes an infinite TTL safe.
Cache-Control: public, max-age=31536000, immutable
HTML entry points — index.html, server-rendered pages, JSON manifests — must not be cached past their actual freshness because they reference the asset URLs. If a browser caches a stale index.html it will request the old hashed filenames, which may no longer exist at the origin.
Cache-Control: no-cache
or, to retain CDN caching while still requiring browser revalidation:
Cache-Control: public, s-maxage=300, no-cache
The second form lets the CDN edge serve HTML for up to five minutes (avoiding origin load) while the browser must always revalidate before using a locally cached copy.
2. Understand max-age and How Caches Consume It
max-age is defined in seconds from the moment the response was generated. Browsers record the response date and count elapsed time. CDNs record when they first stored the object and track Age — the number of seconds the object has been in cache. A client receiving a response with max-age=31536000 and Age: 86400 knows the response has 31449600 seconds of freshness remaining.
The Age header is your diagnostic tool. curl -I https://example.com/assets/main.a3f8c1d2.js should show an increasing Age value on repeated requests (the CDN is serving from cache) and the Cache-Control should match what you configured.
3. Add immutable for Hashed Assets
The immutable extension, defined in RFC 8246, instructs the browser to skip conditional revalidation during the max-age window. Without it, some browsers issue If-None-Match or If-Modified-Since requests at the end of a session or when the user navigates back — even if the asset is still technically fresh. With immutable, the browser treats the cached copy as authoritative for the full TTL period.
Browser support is broad: Chrome, Firefox, Safari, and Edge all respect immutable. CDNs that do not understand immutable treat it as an unknown extension and ignore it safely; the max-age still applies at the CDN layer.
The risk of immutable without fingerprinting: a browser that has cached /app.js with immutable will never check for updates at that URL. This is only safe because fingerprinted builds change the filename. Mixing immutable with non-hashed URLs is a support incident waiting to happen.
4. Choose public or private
public tells every cache in the chain — CDN edge, corporate proxy, shared reverse proxy — that this response may be stored and reused across users. Use it for:
- All hashed static assets
- Non-personalised HTML pages
- Open API responses with stable data
private restricts storage to the end-user’s browser. Use it for:
- Responses containing user-specific data (account pages, shopping carts)
- API responses with
Authorization-derived content - Anything that should not appear in a shared CDN cache
A response with Authorization in the request is treated as private by default unless you explicitly set public. Be deliberate about overriding this default.
5. Use s-maxage to Separate CDN TTL from Browser TTL
s-maxage overrides max-age for shared caches only. The browser still reads max-age. This lets you configure two different TTLs in a single header:
Cache-Control: public, s-maxage=300, max-age=0, stale-while-revalidate=60
In this example, the CDN edge stores the response for five minutes. The browser is told not to cache it at all (max-age=0) but is given a 60-second window to serve stale content during background revalidation. The result: HTML is always fresh in the browser, but the CDN absorbs origin traffic for five minutes between deployments.
Cloudflare, Fastly, and CloudFront all consume s-maxage correctly. Nginx does not interpret s-maxage — you set TTL there via proxy_cache_valid or the Cache-Control header that you emit yourself. See Fastly Surrogate-Control for Fastly’s preferred alternative to s-maxage.
6. Distinguish no-cache from no-store
These are routinely confused. Their semantics differ substantially.
no-cache permits storage. A cache may store the response and may serve it in the future — but only after successfully revalidating with the origin. With ETag-based validation the CDN sends If-None-Match; if the origin returns 304 Not Modified the cached copy is served without retransmitting the body. no-cache is the correct directive for HTML entry points: the browser always checks whether index.html has changed, but the round-trip is cheap because a 304 avoids re-downloading 30 KB of HTML.
no-store prohibits storage entirely. No cache anywhere in the chain may retain the response. Use this for logout responses, session tokens, and any page that contains data that must never be read from cache — regardless of whether revalidation would occur. no-store has a performance cost: every request hits the origin.
Combining no-cache, no-store is redundant but harmless. If you mean “must not cache at all” use no-store. If you mean “always revalidate but storage is allowed” use no-cache.
7. Configure stale-while-revalidate
stale-while-revalidate enables asynchronous revalidation. When the TTL expires, the cache serves the stale response immediately (hiding latency) and simultaneously fires a background request to refresh the cached copy. The next request after the background fetch completes will receive the fresh version.
This is covered in depth in the stale-while-revalidate for HTML entry points page. The short version: for HTML with s-maxage=300, stale-while-revalidate=60, a request that arrives 310 seconds after the last revalidation gets the stale HTML immediately while the CDN fetches a fresh copy. A request 372 seconds after the last revalidation falls outside both the s-maxage and the stale-while-revalidate window — the CDN must wait for the origin before responding.
For hashed assets with max-age=31536000 you do not need stale-while-revalidate — the asset URL never becomes stale because its TTL is one year and you change URLs on each build.
8. Configure stale-if-error
stale-if-error instructs caches to serve a stale response when the origin returns a 5xx error or is unreachable, for up to the specified number of seconds beyond the original TTL.
Cache-Control: public, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
This configuration tells the CDN: serve fresh for five minutes, then serve stale for up to 60 seconds while refreshing, and if the origin goes down serve whatever you have for up to 24 hours. For a site where the HTML entry point and hashed assets are stored at the CDN, an origin outage becomes invisible to users until the stale-if-error window expires.
Cloudflare implements stale-if-error natively and also provides its own “Always Online” mechanism that operates independently. CloudFront respects stale-if-error when you enable it in the cache policy. Fastly supports it via stale-if-error in the Surrogate-Control header.
Per-Provider Configuration
Cloudflare: Cache Rules
In the Cloudflare dashboard, navigate to Caching → Cache Rules. Create two rules.
Rule 1 — Hashed assets (evaluated first):
(http.request.uri.path matches "^/assets/.*\\.[0-9a-f]{8,16}\\.(js|css|woff2|png|webp|avif)$")
→ Cache eligibility: Eligible for cache
→ Edge TTL: Override to 31536000 seconds
→ Browser TTL: Override to 31536000 seconds
→ Respect strong ETags: On
Set the Cache-Control response header via a Transform Rule:
Set header: Cache-Control
Value: public, max-age=31536000, immutable
Rule 2 — HTML entry points:
(http.request.uri.path matches ".*\\.html$" or http.request.uri.path eq "/")
→ Cache eligibility: Eligible for cache
→ Edge TTL: Override to 300 seconds
→ Browser TTL: Bypass
Set the Cache-Control header:
Set header: Cache-Control
Value: public, s-maxage=300, no-cache, stale-while-revalidate=60, stale-if-error=86400
Cloudflare: Workers Headers
If you deploy via Cloudflare Workers rather than Cache Rules, set headers in the fetch handler:
// worker.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
const response = await env.ASSETS.fetch(request);
// Clone so headers are mutable
const headers = new Headers(response.headers);
const isHashedAsset = /\/assets\/[^/]+\.[0-9a-f]{8,16}\.(js|css|woff2|png|webp|avif)$/.test(url.pathname);
const isHtml = url.pathname.endsWith('.html') || url.pathname === '/';
if (isHashedAsset) {
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
} else if (isHtml) {
headers.set('Cache-Control', 'public, s-maxage=300, no-cache, stale-while-revalidate=60, stale-if-error=86400');
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
This pattern integrates with Cloudflare cache purge by URL or cache tag — the Workers layer sets the headers, the Cache Rules layer controls purge scope.
Nginx
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/html;
# Hashed static assets — 1 year, immutable
location ~* ^/assets/[^/]+\.[0-9a-f]{8,16}\.(js|css|woff2|png|webp|avif)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Vary "Accept-Encoding" always;
gzip_static on;
access_log off;
expires 1y;
}
# Fonts — same treatment
location ~* ^/assets/[^/]+\.[0-9a-f]{8,16}\.(woff|woff2|ttf|otf|eot)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
expires 1y;
}
# HTML entry points — must revalidate
location ~* \.html$ {
add_header Cache-Control "no-cache" always;
add_header Vary "Accept-Encoding" always;
try_files $uri $uri/ /index.html;
}
# Root — same as HTML
location = / {
add_header Cache-Control "no-cache" always;
try_files /index.html =404;
}
}
The always flag on add_header ensures the header is sent even on error responses (4xx, 5xx). Without always, Nginx omits custom headers on error codes, which can cause CDNs to cache error pages with your asset TTL.
More detail on Nginx-specific purge integration is in nginx-cache-purge-for-fingerprinted-assets.
AWS CloudFront: Response Headers Policy
Create a CloudFront Response Headers Policy via the AWS Console or CLI.
{
"Name": "fingerprinted-assets-policy",
"Comment": "Cache-Control headers for hashed assets and HTML",
"CustomHeadersConfig": {
"Quantity": 1,
"Items": [
{
"Header": "Cache-Control",
"Value": "public, max-age=31536000, immutable",
"Override": true
}
]
}
}
Apply via AWS CLI:
# Create the policy for hashed assets
aws cloudfront create-response-headers-policy \
--response-headers-policy-config file://hashed-assets-policy.json \
--region us-east-1
# Attach to a cache behavior in your distribution
aws cloudfront update-distribution \
--id EDFDVBD6EXAMPLE \
--if-match $(aws cloudfront get-distribution-config \
--id EDFDVBD6EXAMPLE \
--query 'ETag' --output text) \
--distribution-config file://distribution-config.json
In distribution-config.json, set the ResponseHeadersPolicyId on the path pattern /assets/* cache behavior. For HTML, attach a separate policy:
{
"Name": "html-entry-policy",
"CustomHeadersConfig": {
"Quantity": 1,
"Items": [
{
"Header": "Cache-Control",
"Value": "public, s-maxage=300, no-cache, stale-while-revalidate=60, stale-if-error=86400",
"Override": true
}
]
}
}
See AWS CloudFront invalidation strategies for pairing these headers with programmatic cache invalidation on deploy.
Fastly: Surrogate-Control vs Cache-Control
Fastly distinguishes between Cache-Control (forwarded to the browser) and Surrogate-Control (consumed by Fastly edge nodes and stripped before the response reaches the browser). This gives you independent TTLs without a combined directive.
# In vcl_deliver, set Surrogate-Control for the edge
# and a separate Cache-Control for the browser
sub vcl_deliver {
#FASTLY deliver
if (req.url ~ "^/assets/[^/]+\.[0-9a-f]{8,16}\.(js|css|woff2|png|webp|avif)(\?.*)?$") {
# Edge caches for 1 year
set resp.http.Surrogate-Control = "public, max-age=31536000, stale-if-error=604800";
# Browser also caches for 1 year with immutable
set resp.http.Cache-Control = "public, max-age=31536000, immutable";
} else if (req.url ~ "\.html$" || req.url == "/") {
# Edge caches for 5 minutes with SWR
set resp.http.Surrogate-Control = "public, max-age=300, stale-while-revalidate=60, stale-if-error=86400";
# Browser always revalidates
set resp.http.Cache-Control = "no-cache";
}
return(deliver);
}
Fastly strips Surrogate-Control before sending to the client, so browsers never see edge-only TTLs. This is documented further in Fastly Surrogate Keys for fingerprinted assets.
TTL Timeline Diagram
Verification
Inspect headers with curl
# Check a hashed asset — expect Cache-Control with immutable and Age increasing on repeat calls
curl -Is https://example.com/assets/main.a3f8c1d2.js | grep -i 'cache-control\|age\|cf-cache-status\|x-cache'
# Second call — Age should be higher if CDN is caching
curl -Is https://example.com/assets/main.a3f8c1d2.js | grep -i 'age'
# Check HTML — expect no-cache or short s-maxage
curl -Is https://example.com/ | grep -i 'cache-control\|age'
Expected output for a hashed asset:
cache-control: public, max-age=31536000, immutable
age: 142
cf-cache-status: HIT
If age is always 0 and cf-cache-status is always MISS, the CDN is not caching — check that your origin is not sending Set-Cookie (which bypasses Cloudflare’s cache by default) or that Cache-Control: private is not appearing from an upstream middleware.
Verify stale-while-revalidate behaviour
# Record initial Last-Modified or ETag
ETAG=$(curl -Is https://example.com/ | grep -i 'etag' | awk '{print $2}')
# Wait for s-maxage to expire (e.g., 310 seconds if s-maxage=300)
sleep 310
# First request after expiry — should still return 200 quickly (stale served)
time curl -Is https://example.com/ | grep -i 'cache-control\|age\|x-cache'
# Second request — now served from the freshly fetched copy
time curl -Is https://example.com/ | grep -i 'cache-control\|age'
If the first post-expiry request takes longer than a few milliseconds and matches your origin response time, stale-while-revalidate is not being honoured. Check that your CDN’s cache policy has SWR enabled — CloudFront requires explicit opt-in in the cache policy, it does not read the header value automatically.
Confirm immutable prevents conditional requests
# Strip cookies and simulate a browser reload by sending If-None-Match
curl -Is \
-H "If-None-Match: \"some-etag-value\"" \
https://example.com/assets/main.a3f8c1d2.js
# Expected: 200 OK (not 304) — the CDN/browser should not be asking at all for immutable assets
# If you see 304, the asset is not being cached with immutable or the CDN is misconfigured
Edge Cases and Known Issues
CloudFront ignores stale-while-revalidate by default. You must enable it explicitly in the CloudFront cache policy under “Stale-while-revalidate” and “Stale-if-error” settings. Setting the header alone is insufficient.
Cloudflare overrides Cache-Control for certain content types. If the origin sends a response with Content-Type: text/html and no explicit Cache-Control, Cloudflare defaults to not caching. Always set explicit headers rather than relying on default behaviour.
immutable is ignored by HTTP/1.1 proxies. Enterprise forward proxies running HTTP/1.1 do not understand immutable as a cache extension and will fall through to max-age only. This is safe — the asset still has a one-year TTL.
no-cache on HTML with Cloudflare’s “Cache Everything” page rule. If you have a Page Rule set to “Cache Everything”, Cloudflare will cache HTML regardless of Cache-Control: no-cache from the origin. The no-cache tells the browser not to serve without revalidating; it does not tell Cloudflare to bypass its own cache. Use an explicit Edge TTL override of 0 in Cache Rules to prevent this conflict.
Missing Vary: Accept-Encoding on compressed assets. If you serve Brotli and Gzip variants of the same hashed asset, include Vary: Accept-Encoding alongside Cache-Control. Without it, a CDN that cached the Gzip variant may serve it to a Brotli-capable client. This is separate from fingerprinting but important when max-age=31536000 is in play.
s-maxage and Nginx. Nginx’s proxy_cache module does respect s-maxage when proxy_cache_use_stale is set, but the behaviour depends on whether proxy_ignore_headers Cache-Control is used. In most Nginx configurations you are better off setting proxy_cache_valid explicitly and treating s-maxage as a downstream CDN directive.
Large stale-if-error windows and deploys. If you deploy new HTML with stale-if-error=604800 and your origin goes down within 10 minutes of deploy, users may get week-old HTML for up to seven days. Balance resilience against the risk of serving dramatically stale entry points. A 24-hour stale-if-error on HTML is usually a reasonable upper bound.
Performance Impact
For a typical single-page application, the combined cache strategy described here produces:
- Zero conditional requests for hashed assets. No
If-None-Matchtraffic to origin or CDN for the one-year TTL window. - Near-zero origin load for assets after the first edge population. CDN hit ratios above 99% are common within minutes of a deploy.
- One revalidation request per HTML resource per
s-maxageinterval per CDN PoP. With a global CDN and 300-seconds-maxage, a popular page might produce 100–200 revalidation requests per second from edge nodes to origin. This is far lower than the raw request rate; it scales with PoP count, not user count. - Sub-millisecond latency improvement from eliminating ETag round trips on returning visits. A
304 Not Modifiedresponse still requires a full TCP/TLS round trip;immutableeliminates it.
The cache key architecture page covers how these TTLs interact with cache key design at the CDN level, including the query-string normalization issues that can cause max-age=31536000 responses to be cached separately per query variant.
The deterministic build outputs page is a prerequisite for immutable: if your build is not deterministic, running it twice produces different hashes for identical source, and immutable will cause browsers to hold stale assets for a year.
FAQ
Does immutable work in Safari?
Yes. Safari has supported immutable since Safari 12.1 (2019). All major browsers — Chrome 49+, Firefox 49+, Safari 12.1+, Edge 79+ — implement it. The extension is defined in RFC 8246 as a registered cache directive extension. Browsers that predate support treat it as an unknown token and ignore it gracefully; max-age still applies.
What happens if I set max-age=31536000, immutable on a non-hashed asset and then push a change?
Browsers that have cached the response will serve the old bytes for up to one year with no way to force a refresh short of the user clearing their cache manually. This is why immutable must only appear on URLs that will never serve different content — i.e., content-hashed filenames where the URL itself changes on every build. It is not a directive to use defensively.
Can I use s-maxage and max-age=0 together to let the CDN cache but force browsers to always hit the CDN?
Yes, this is a valid pattern. Cache-Control: public, s-maxage=300, max-age=0 tells the CDN to cache for five minutes and tells the browser its local copy is immediately stale. The browser will issue a conditional request on every page load, but that request hits the CDN (returning a fast 304 if the CDN’s copy is still fresh) rather than your origin. Add stale-while-revalidate=60 to make even the CDN→browser interaction asynchronous.
How do I handle assets that are hashed but served from a CDN that ignores Cache-Control from the origin?
Some CDNs (and some misconfigured deployments) strip or ignore origin Cache-Control headers and apply their own default TTL. In this case, configure the TTL directly in the CDN’s cache policy (CloudFront cache policy, Cloudflare Cache Rules Edge TTL, Fastly beresp.ttl in VCL). The origin header and the CDN-level configuration should agree, but the CDN-level setting takes precedence in most platforms. Always verify with curl -I and check the Age header as described in the verification section above.
Related
- CDN Purge Strategies — parent overview of cache invalidation patterns across CDN providers
- stale-while-revalidate for HTML entry points — deep dive into asynchronous revalidation for entry point documents
- Cloudflare Cache Rules and Purge — configuring TTLs and tag-based purge together in Cloudflare
- Fastly Instant Purge — Fastly’s
Surrogate-Controlheader and surrogate key purge model - ETag vs immutable Cache-Control for assets — when conditional request validation is appropriate and when to skip it entirely