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 immutable directive is only safe on content-hashed URLs and how it changes reload behavior.
  • Mastering max-age and s-maxage directives — the freshness window immutable layers on top of; you need to understand how max-age=31536000 is computed and honored before adding the extension.
  • no-cache vs no-store — the directive you’ll apply to index.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:

  1. Open DevTools → Network tab, leave “Disable cache” unchecked.
  2. Load the page once (cold load) — main.b7f31e2a.js shows as a normal 200 network fetch.
  3. Reload the page (Ctrl/Cmd+R, not a hard reload).
  4. Click the asset request. The Size column should read (disk cache) or (memory cache), and the Status should read 200, not 304.

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) includes Cache-Control: no-cache and never immutable or a long max-age.
  • Reloading the page produces zero 304 responses for hashed assets — either no request at all (Firefox/Safari) or a 200 (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.html referencing it.
  • curl -sI against a hashed asset shows the immutable token 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 cache index.html longer than intended. Always verify the client-observed header with curl against the production domain, not just the origin.
  • HTTP/1.1 vs HTTP/2 — no difference in caching semantics — both protocol versions honor Cache-Control identically; 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 mark immutable.
  • 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-age is relative (seconds from Date), unlike Expires, 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.


Back to Immutable and Versioned Asset Caching