Setting up immutable cache headers for hashed assets
This walkthrough extends HTTP Cache-Control Headers Explained within Advanced Caching Strategies & CDN Architecture, targeting one precise failure: hashed bundles that still pay for revalidation.
Modern build pipelines emit content-hashed filenames such as app.8f3a9c.js to guarantee uniqueness across deployments. Yet browsers still issue conditional If-None-Match or If-Modified-Since requests on reloads, generating 304 Not Modified responses that cost a TCP handshake, TLS negotiation, and origin CPU even though no body is transmitted. On a throttled connection that round-trip adds 30-120ms of validation jitter to the critical path. The immutable directive removes it: it tells the browser the resource will never change during its max-age, so it is served straight from disk or memory with zero network contact.
Rapid Diagnosis: A DevTools and Lighthouse Checklist
Run this sequence before and after deployment to isolate validation overhead from real transfer cost.
- Open Chrome DevTools > Network and confirm Disable cache is unchecked.
- Perform a hard reload (
Ctrl+Shift+R/Cmd+Shift+R). - Filter by JS or CSS and flag any hashed file returning a
304status. - Run Lighthouse and locate the Serve static assets with an efficient cache policy audit.
- Flag assets with
max-age < 31536000or a missingimmutabledirective. - Target thresholds: 0% 304 responses for hashed assets, > 95% Lighthouse cache-policy score, TTFB ≤ 200ms (0-10ms when served from browser cache).
If Lighthouse still flags assets after the change, an intermediate proxy or a misconfigured CDN cache key is stripping the directive — verify the raw response, not the dashboard.
Root Cause Analysis: Four Reasons the 304 Persists
1. Default freshness validation. Browsers revalidate on hard reload and back/forward navigation. Even with the asset in the HTTP cache, a conditional GET checks the ETag against the origin and gets a 304. immutable is the only directive that suppresses this for the max-age window.
2. Stripped directive at the edge. Some CDNs and reverse proxies rewrite or drop Cache-Control on responses they consider dynamic, or normalize headers through a transform layer. The browser then never sees immutable.
3. Cache-key fragmentation. When the CDN keys on query strings or User-Agent, the same hashed URL splinters into many cache objects, so even correct headers produce low hit rates and surprise origin contacts.
4. Service-worker interception. A service worker fetch handler can bypass the browser HTTP cache entirely and refetch the asset over the network, masking the directive's effect regardless of what the origin sends.
Step-by-Step Resolution, Ordered by Impact
Configuration must target only hashed assets. Applying immutable to index.html or API responses pins users to permanently stale content.
1. Match the hash pattern at the edge or proxy
This is the highest-leverage fix because it removes the round-trip for every visitor.
location ~* \.[0-9a-f]{8,}\.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Cache-Status $upstream_cache_status;
}
# trade-off: the {8,} hex pattern matches only hex hashes. Base62/base64url
# hashing (some Webpack/Rollup configs) will not match and silently stays
# revalidated — confirm your hash alphabet before relying on this regex.
Expected outcome: eliminates the conditional GET for matched assets, reducing repeat-visit TTFB from ~30-120ms to 0-10ms.
2. Inject conditionally in Node when you control static serving
app.use('/assets', (req, res, next) => {
if (/\.[0-9a-f]{8,}\.(js|css)$/.test(req.path)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
next();
});
// trade-off: this runs per request in app code. Under high static traffic,
// prefer the proxy/CDN layer (step 1) so the Node event loop never touches
// these requests at all.
Expected outcome: removes 304s for hashed assets served through the app tier; reduces network payload on repeat visits by > 40%.
3. Pin the build so the regex always has something to match
Hashing must be deterministic and present before any header logic can be trusted, so validate the manifest in CI rather than assuming the pattern fired.
{
"build": {
"rollupOptions": {
"output": { "assetFileNames": "assets/[name]-[hash][extname]" }
}
}
}
Expected outcome: guarantees every emitted asset carries a hash segment the header rules recognize, closing the gap where an unhashed file would be cached for a year by mistake.
4. Add the directive at the CDN layer for non-origin paths
In Cloudflare, use Rules > Transform Rules > Modify Response Header, target paths matching the hash pattern, and set Cache-Control to public, max-age=31536000, immutable. For deciding which paths are safe to mark immutable versus which need a deletion strategy, cross-check HTTP Cache-Control Headers Explained so the directive lands only on truly content-addressed URLs.
Verification: Before/After, CI, and the Field
Confirm the header survives all the way to the client.
curl -sD - -o /dev/null https://yourdomain.com/assets/app.8f3a9c.js | grep -i cache-control
# trade-off: this checks one origin response. It does NOT prove the CDN edge
# kept the directive — also curl through the edge hostname and inspect
# CF-Cache-Status / X-Cache to confirm propagation across PoPs.
The response must be 200 OK with immutable intact. A subsequent reload that the browser would normally treat as max-age=0 is still served from cache, because immutable overrides the revalidation hint. Wire the same check into CI so a directive regression fails the merge, and watch your RUM beacon for an LCP variance drop of 15-30ms on 3G/4G profiles and a CDN hit ratio climbing toward ≥ 98% for static assets in the days after rollout.
Common Mistakes
- Applying
immutableto HTML, JSON, or unversioned endpoints, causing permanent stale delivery. - Omitting
max-agealongsideimmutable, which falls back to browser heuristics and breaks the directive. - Combining
no-cacheormust-revalidatewithimmutablein one header string, creating conflicting instructions. - Leaving CDN cache keys keyed on query strings, fragmenting the cache despite correct headers.
- Deploying hashed assets without verifying the hash matches the build manifest, causing 404s after a purge.
Safe Rollback and Invalidation
Immutable assets are bound to unique hashes, so a rollback simply redeploys the previous hash — the old URL stays valid and cached, and no purge is needed. The risk lives in the manifest that points HTML at the hashes; reverting that manifest is the actual rollback step. When you do need to retire an immutable URL deliberately (a leaked secret in a bundle, a poisoned object at the edge), follow invalidating immutable hashed assets safely, which sequences the manifest swap against the edge purge so no client ever resolves a dangling hash.
Related
- HTTP Cache-Control Headers Explained covers directive precedence and where immutable fits among the rest.
- Advanced Caching Strategies & CDN Architecture places asset caching in the full delivery stack.
- Invalidating immutable hashed assets safely is the deliberate-retirement counterpart to this guide.
- Service worker caching strategies prevents a SW fetch handler from masking the directive.
- CDN edge caching configuration fixes the cache-key fragmentation that undermines hit rates.