Caching Hashed Static Assets with immutable
Frontend deployments frequently ship JavaScript, CSS, and image assets that a browser will keep re-checking on every reload even though the bytes never change for that specific URL. The fix has two parts that must both be in place simultaneously: a build step that guarantees the URL changes whenever the content does, and a caching header that tells the browser it can trust that guarantee. This guide walks through configuring both, using Vite and webpack as the reference bundlers, and shows exactly how to confirm — with curl and DevTools — that no revalidation request fires on a normal reload.
Prerequisite Concepts
Before configuring the bundler and server, make sure you’re comfortable with the underlying model:
- Immutable and versioned asset caching — the mechanism this guide implements: why the
immutabledirective is only safe on content-hashed URLs and how it changes reload behavior. - Mastering
max-ageands-maxagedirectives — the freshness windowimmutablelayers on top of; you need to understand howmax-age=31536000is computed and honored before adding the extension. no-cachevsno-store— the directive you’ll apply toindex.html, which must behave oppositely from the hashed assets it references.
Step-by-Step Resolution
Step 1 — Enable content hashing in the bundler
Vite hashes output filenames by default in production builds; the pattern is configurable in vite.config.js:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
};
webpack requires [contenthash] explicitly in output.filename (not [hash], which changes on every build regardless of content, or [chunkhash], which changes per-chunk but not strictly per byte-for-byte content):
// webpack.config.js
module.exports = {
output: {
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].chunk.js',
assetModuleFilename: 'assets/[name].[contenthash:8][ext]',
},
};
Run a production build and confirm the output filenames contain the hash segment:
npx vite build && ls dist/assets/
# main.b7f31e2a.js main.b7f31e2a.css vendor.9c02af41.js
Step 2 — Serve hashed assets with immutable
For a Node/Express server fronting the build output directly:
// server.js
const express = require('express');
const app = express();
app.use('/assets', express.static('dist/assets', {
setHeaders(res, path) {
if (/\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg)$/.test(path)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
}));
For Nginx serving the same build output as static files behind a reverse proxy:
location ~* ^/assets/.+\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg)$ {
root /var/www/dist;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
The regex is intentionally strict — it matches only filenames containing an 8+ character hex hash segment, so it never accidentally applies to a non-hashed static file living in the same directory (a favicon, a manually-named PDF, etc.).
Step 3 — Exclude index.html from long-lived caching
The HTML entry document references the current hashed filenames and must be revalidated on every request, or clients will keep loading an outdated manifest that points at asset URLs a subsequent deploy may have removed.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
// Express — index.html served separately from hashed assets
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
If you’re behind a CDN, confirm the CDN doesn’t apply its own default edge TTL to /index.html that overrides this header — check for a platform-level Cache Rule or page rule scoped to the HTML path and set its edge TTL to respect origin headers, or to zero.
Step 4 — Verify no revalidation on reload
Confirm the header on the built asset:
curl -sI https://example.com/assets/main.b7f31e2a.js \
| grep -iE 'cache-control|etag'
Expected:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "b7f31e2a"
Confirm the HTML entry point is excluded:
curl -sI https://example.com/ | grep -i cache-control
Expected:
Cache-Control: no-cache
Then confirm in-browser behavior with Chrome DevTools:
- Open DevTools → Network tab, leave “Disable cache” unchecked.
- Load the page once (cold load) —
main.b7f31e2a.jsshows as a normal200network fetch. - Reload the page (Ctrl/Cmd+R, not a hard reload).
- Click the asset request. The Size column should read
(disk cache)or(memory cache), and the Status should read200, not304.
In Firefox or Safari, the request may not even appear as a distinct network entry on reload — those browsers implement the reload-revalidation suppression immutable enables. In Chrome, the request still appears in the panel, but the 200 (from disk cache) status confirms no conditional request reached the origin.
Expected Output / Verification
A correctly configured deployment shows all of the following:
- Every hashed asset response includes
Cache-Control: public, max-age=31536000, immutable. index.html(or any HTML entry document) includesCache-Control: no-cacheand neverimmutableor a longmax-age.- Reloading the page produces zero
304responses for hashed assets — either no request at all (Firefox/Safari) or a200 (from disk cache)response (Chrome). - A subsequent deploy that changes a source file produces a new hash in the built filename; the old hashed URL remains resolvable at the CDN/origin for as long as any client might still hold a stale
index.htmlreferencing it. curl -sIagainst a hashed asset shows theimmutabletoken verbatim — if it’s missing from the client-observed response but present at the origin, a CDN or intermediate proxy is stripping it.
Edge Cases
- CDN default TTL overriding origin headers — some CDNs apply a platform-level minimum edge TTL to all responses regardless of origin
Cache-Control, which can inadvertently cacheindex.htmllonger than intended. Always verify the client-observed header withcurlagainst the production domain, not just the origin. - HTTP/1.1 vs HTTP/2 — no difference in caching semantics — both protocol versions honor
Cache-Controlidentically; only the transport (multiplexing, header compression) differs. Don’t expect different caching behavior when testing over HTTP/2. - Missing hash on dynamically imported chunks — code-split chunks loaded via dynamic
import()must also go through the bundler’s hashing pipeline. If a build configuration only hashes the entry bundle and not lazy-loaded chunks, those chunks are unsafe to markimmutable. - Source maps referencing old hashes — if source maps are deployed separately from their corresponding hashed JS file, a mismatch after a partial deploy can leave DevTools unable to resolve a map. Deploy hashed assets and their source maps as a single atomic unit.
- Clock skew is irrelevant here — because
max-ageis relative (seconds fromDate), unlikeExpires, this configuration has no dependency on synchronized clocks between origin and CDN.
Frequently Asked Questions
Does index.html need Cache-Control at all?
Yes. Omitting the header lets the server or CDN fall back to heuristic freshness — an implementation-defined guess, often based on file modification time — that you cannot rely on to revalidate promptly. Set an explicit no-cache so every request for the HTML entry document checks with the origin (or CDN) before use.
What if my CDN caches index.html longer than I configured?
Check for a platform-level Cache Rule, page rule, or default edge TTL that applies to the HTML path pattern and overrides the origin’s no-cache. Most CDNs let you scope a rule specifically to /, /index.html, or a file-extension match to force the CDN to respect the origin header or apply its own zero/near-zero TTL.
Can I use immutable without a build tool that hashes filenames?
Only by manually embedding and rigorously maintaining a version identifier in every asset URL — a process that’s easy to get wrong under manual discipline. A bundler’s automated content-hash output removes that risk and should be treated as the required foundation, not an optional nicety.
Why does Chrome still show a network request for a cached hashed asset on reload?
Chrome does not implement the specific reload-revalidation suppression that immutable enables in Firefox and Safari. The request still appears in the Network panel, but its status should read 200 (from disk cache) rather than 304, which confirms no round trip to the origin occurred — the practical benefit is preserved even though the DevTools entry looks similar to an uncached request.
Related
- How to combine Cache-Control directives safely — general rules for stacking multiple directives without producing contradictory freshness behavior across tiers.
- How CDN cache keys are generated — relevant if you use query-string versioning instead of path-embedded hashes, since some CDNs normalize query strings out of the cache key.
- When does a browser invalidate a cached resource? — the broader browser-side eviction and revalidation model that this configuration deliberately short-circuits for hashed assets.