Setting up immutable cache headers for hashed assets

Modern frontend build pipelines generate content-hashed filenames (e.g., app.8f3a9c.js) to guarantee asset uniqueness across deployments. Despite this, browsers still issue conditional If-None-Match or If-Modified-Since requests on page reloads. These generate unnecessary 304 responses and add measurable network latency.

The immutable directive solves this by explicitly telling the browser that a cached resource will never change during its max-age period. It bypasses revalidation entirely. Before implementing, reviewing the foundational mechanics in HTTP Cache-Control Headers Explained ensures correct directive placement and prevents cache poisoning.

Root Cause Analysis: Why Hashed Assets Trigger 304s Without immutable

Browsers default to a freshness validation strategy during hard reloads or back/forward navigation. Even when a hashed asset exists in the HTTP cache, the browser sends a conditional GET request. It verifies the ETag or Last-Modified timestamp against the origin.

The server responds with a 304 Not Modified. This still consumes TCP handshakes, TLS negotiation, and server CPU cycles. The Cache-Control: immutable directive overrides this behavior. It declares that the resource URL is permanently bound to its content hash. Once cached, the browser serves it directly from disk or memory until max-age expires.

DevTools & Lighthouse Diagnostic Workflow

Execute this diagnostic sequence before and after header deployment to isolate validation overhead.

  • Open Chrome DevTools > Network tab. Ensure Disable cache is unchecked.
  • Perform a hard reload (Ctrl+Shift+R / Cmd+Shift+R).
  • Filter by JS or CSS. Identify any hashed files returning 304 status codes.
  • Run Lighthouse via the Performance tab. Locate the Serve static assets with an efficient cache policy audit.
  • Flag assets with max-age < 31536000 or missing the immutable directive.
  • Target thresholds: 0% 304 responses for hashed assets, >95% Lighthouse cache policy score, and <50ms TTFB for cached resources.
  • If Lighthouse continues to flag assets, verify that intermediate proxies or misconfigured CDN cache keys are not stripping the directive.

Step-by-Step Configuration: Build Tools & Edge Servers

Configuration must target only hashed assets. Applying immutable to unversioned files like index.html or API responses causes permanent stale content delivery.

Webpack: Use webpack-manifest-plugin to map hashes. Inject headers via a custom static file server or Node middleware.

Vite: Configure build.rollupOptions.output.assetFileNames to include hashes. Delegate header assignment to your hosting layer or reverse proxy.

Nginx: Apply a regex location block matching hash patterns.

nginx
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;
}

Cloudflare Edge: Deploy a Transform Rule matching hash patterns.

yaml
transforms:
 - name: immutable-assets
 rule:
 target:
 url: "*.hash.*"
 action:
 set_headers:
 - name: Cache-Control
 value: "public, max-age=31536000, immutable"
 operation: set

Express/Node Middleware: Inject headers conditionally during static serving.

javascript
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();
});

Always verify the exact header string in the raw response before deployment.

Metric Thresholds & Production Validation

Monitor CDN analytics and Real User Monitoring (RUM) data immediately post-deployment. Validate against these strict thresholds:

  • CDN cache hit ratio: ≥98% for static assets.
  • TTFB for cached hashed files: 0-10ms (served directly from browser cache).
  • Network payload reduction: >40% on repeat visits.
  • Core Web Vitals: LCP variance typically drops by 15-30ms on 3G/4G networks when validation jitter is eliminated.

Verify header persistence using curl:

bash
curl -I -H 'Cache-Control: max-age=0' https://yourdomain.com/assets/app.[hash].js

The response must return 200 OK with the immutable directive intact. This proves the directive overrides conditional requests even when clients request fresh validation.

Edge Cases & Integration with Service Workers

Service workers intercept fetch requests and can bypass browser cache headers if misconfigured. Ensure your SW fetch handler respects request.cache or explicitly checks caches.match() before falling back to the network.

When combining immutable with stale-while-revalidate, apply immutable only to the hashed payload layer. Do not apply it to the SW cache strategy itself. For complex routing, implement a strict asset manifest validation step in CI/CD to prevent header misapplication.

Understanding how edge nodes propagate these directives across global PoPs is essential for scaling. This aligns with broader architectural patterns covered in Advanced Caching Strategies & CDN Architecture.

Common Mistakes

  • Applying immutable to HTML, JSON, or unversioned API endpoints, causing permanent stale content delivery.
  • Omitting max-age alongside immutable, which defaults to browser heuristics and breaks the directive.
  • Using no-cache or must-revalidate in the same header string, creating conflicting cache directives.
  • Failing to update CDN cache keys to ignore query strings, causing cache fragmentation despite correct headers.
  • Deploying hashed assets without verifying the hash matches the build output, leading to 404s on cache purge.

FAQ

Does Cache-Control: immutable work if the user clears their browser cache? No. The directive only applies to resources already stored in the browser's HTTP cache. Once cleared, the browser must fetch the asset from the origin or CDN again. It will respect the directive for subsequent visits.

Can I use immutable alongside stale-while-revalidate? Technically yes, but it is architecturally redundant. immutable guarantees zero revalidation, while stale-while-revalidate explicitly allows background validation. Use immutable for hashed static assets and stale-while-revalidate for dynamic content.

Why does Lighthouse still flag my hashed assets after adding immutable? Lighthouse flags assets if max-age is below 31536000, if the header is malformed, or if a service worker intercepts the request and modifies cache behavior. Verify raw response headers via DevTools and ensure your SW fetch handler does not override Cache-Control.

How do I safely purge immutable assets during a deployment rollback? Immutable assets are tied to unique hashes. A rollback simply deploys the previous hash version. Since browsers cache by URL, the old hash remains valid and cached. You do not need to purge immutable assets; revert the build manifest to point to the previous hash.