Nginx Cache-Control Immutable: Speed Up Static Asset Caching

by Fahim

Hit reload on your web app, and watch your browser fire off dozens of conditional HTTP requests asking if fingerprinted CSS and JS bundles changed. If the filename already contains a unique build hash, that round-trip check is completely pointless. Here’s how to configure Nginx with Cache-Control: immutable so browsers grab those files straight from disk cache without asking twice.

Close-up of server rack hardware glowing under blue status LEDs representing Nginx web server caching infrastructure
Close-up of server rack hardware glowing under blue status LEDs representing Nginx web server caching infrastructure

The Hidden Latency of Browser Revalidations

I ran into this while auditing an app where page refreshes felt sluggish despite setting max-age=31536000 everywhere. When a user hits reload (F5 or Command+R), browsers don’t treat cached files the same way they do during regular link navigation. Instead of reading straight from disk, the browser halts and throws a conditional request (If-Modified-Since or If-None-Match) back to your origin.

Even when Nginx responds with a quick 304 Not Modified, the round-trip latency still hurts. On a mobile connection with 80ms to 120ms latency, waiting on 30 conditional 304 requests stalls the critical rendering path. The browser sits there waiting for network confirmation before evaluating stylesheet and script tags.

Here’s how reloads behave with and without the immutable directive:

  • Normal navigation: The browser reads main.7a1b8c.js directly from disk cache without contacting your server.
  • Standard reload: The browser halts, issues a conditional GET to Nginx, waits out the latency, gets a 304 Not Modified back, and only then executes the file.
  • Reload with immutable: The browser ignores the reload trigger for fingerprinted assets and reads straight from disk cache in 0ms.

If your build system adds content hashes to filenames (Vite, Webpack, esbuild, or Next.js all do this out of the box), the file contents will never change under that exact URL. Asking Nginx if bundle.d82a4f.css changed is a completely wasted network trip.

How Cache-Control: immutable Works Under the Hood

The RFC 8246 specification defined the immutable extension for HTTP Cache-Control headers. When a response carries this token, the browser knows the response body will never change while it remains fresh within its max-age window.

According to MDN Web Docs, Firefox, Chrome, and Safari bypass conditional validation during user-initiated reloads whenever immutable is present. The only time a browser re-requests an immutable resource is when the cache expires or when the user triggers a hard refresh (Ctrl+F5 or Shift+Reload).

A production-ready cache header for fingerprinted files looks like this:

Cache-Control: public, max-age=31536000, immutable

The public directive lets intermediate proxies and CDNs store the file. max-age=31536000 tells clients to cache it for 365 days (1 year). And immutable tells the browser engine that conditional revalidations during that year are completely unnecessary.

The Trap: Nginx add_header Inheritance Silently Drops Headers

Before touching your config, you need to know how Nginx handles directive inheritance. This is the exact gotcha that trips up most teams when adding static asset rules.

In Nginx, if you define add_header inside an http or server block, and then define another add_header inside a nested location block, all parent headers are wiped out for requests matching that location block. The official Nginx documentation on add_header states that directives are inherited from the previous configuration level only if no add_header directives are defined on the current level.

I got burned by this firsthand: I set up security headers in my root server block, then added a static cache location block with a new add_header. Instantly, all security headers (like X-Content-Type-Options and CORS headers) vanished from our CSS and JS files. If you run into font errors or broken cross-origin requests, check our tutorial on how to fix CORS font loading errors on CDN with Nginx.

Setting Up Immutable Headers for Hashed Assets in Nginx

Now let’s configure Nginx to separate hashed assets from non-hashed files. Never apply immutable to generic asset names like style.css or logo.png. If you update that file, your users will remain stuck on the old cached version for an entire year.

Open your Nginx virtual host configuration. On Debian and Ubuntu systems, it’s typically in /etc/nginx/sites-available/.

sudo nano /etc/nginx/sites-available/your-domain.conf

Add a dedicated location block targeting hashed static assets. Modern frontend tools usually dump these under paths like /assets/ or /_next/static/, or append a hash string right before the file extension:

server { listen 80; listen [::]:80; server_name example.com; root /var/www/example.com/dist; index index.html; # 1. Hashed Vite / Rollup assets (e.g., /assets/vendor-d8f93e.js) location ~* ^/assets/.*-[a-f0-9]{8,}\.(?:css|js|woff2?|png|jpe?g|webp|svg)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off; try_files $uri =404; } # 2. Next.js hashed assets location ^~ /_next/static/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; access_log off; try_files $uri =404; } # 3. Fallback for unhashed static files location ~* \.(?:ico|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$ { expires 7d; add_header Cache-Control "public, max-age=604800, must-revalidate" always; access_log off; try_files $uri =404; } # 4. Single-page application entry point (never cache HTML) location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate" always; }
}

Notice the always flag at the end of the add_header directive. This ensures Nginx sends the header even if the server returns error response codes.

If you’re deploying a Next.js frontend alongside your reverse proxy setup, our walkthrough on how to deploy Next.js on a VPS with PM2 and Nginx covers the exact folder structure shown above.

Combining Immutable with Pre-compressed Brotli and Gzip

Static assets that benefit from immutable caching are also prime candidates for compression. Having Nginx compress files dynamically on every incoming request burns CPU cycles for nothing. Instead, serve pre-compressed .br and .gz files generated directly during your build step.

Here’s how to combine pre-compression with immutable headers inside your static location block:

# Enable pre-compression modules
gzip_static on;
brotli_static on; location ~* ^/assets/.*-[a-f0-9]{8,}\.(?:css|js)$ { gzip_static on; brotli_static on; expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; add_header Vary "Accept-Encoding" always; access_log off; try_files $uri =404;
}

The Vary: Accept-Encoding header tells reverse proxies and browsers that the returned byte payload differs based on client decompression support. To configure Nginx to build and serve these binary files properly, read our step-by-step guide to serve pre-compressed Brotli and Gzip assets on Nginx.

Verifying Headers with Curl and Browser DevTools

Always test your changes using nginx -t before reloading the systemd service:

sudo nginx -t

If the syntax test passes, reload Nginx without dropping active connections:

sudo systemctl reload nginx

Now, test a hashed asset URL using curl with the -I flag to inspect the response headers:

curl -I https://example.com/assets/index-c3a1d9b8.js

You should see a clean response confirming the caching directive:

HTTP/2 200
server: nginx
date: Sun, 16 Feb 2025 14:22:10 GMT
content-type: application/javascript
content-length: 48921
last-modified: Sun, 16 Feb 2025 12:05:00 GMT
etag: "67b1daec-bf19"
cache-control: public, max-age=31536000, immutable
vary: Accept-Encoding
accept-ranges: bytes

Next, open your browser’s Developer Tools (F12), switch to the Network tab, and hit reload. Under the Size column, requests for these immutable assets should display (disk cache) or (memory cache) with 0 ms or 1 ms response times. No conditional 304 Not Modified status codes should show up for hashed assets.

Handling Mutable Assets: Don’t Break Your HTML or Favicon

The biggest production mistake teams make with immutable is casting too wide a net with regular expressions. Never mark these files as immutable:

  • HTML pages: index.html must always revalidate, otherwise visitors will never fetch updated script hashes when you push a new release.
  • Service Workers: sw.js needs frequent checking so clients register updated worker scripts.
  • Manifests and favicons: site.webmanifest and favicon.ico rarely have build hashes in their URLs.
  • WordPress media uploads: Standard WordPress images inside wp-content/uploads do not have content hashes unless managed by an asset pipeline plugin.

If you run a Content Delivery Network like StackPath in front of Nginx, you must also ensure your edge nodes respect these headers and ignore external tracking parameters. You can inspect how query strings affect cache hits in our tutorial on how to configure CDN edge cache rules to ignore tracking query strings.

Frequently Asked Questions

Can I use query strings instead of file content hashes with immutable?

Technically, a URL like main.js?v=1.2.4 will accept the immutable header. However, some corporate proxies and older CDN tiers ignore caching entirely on requests containing query strings. Using filename hashing (such as main.1a2b3c.js) is much more reliable across all network environments.

What happens when a user presses Ctrl+F5 (Hard Refresh)?

A hard refresh forces the browser to discard its local cache headers for that request. The browser sends Cache-Control: no-cache and Pragma: no-cache request headers to Nginx, and Nginx returns a full 200 OK with the file payload. The immutable directive only prevents revalidations during normal navigations and soft reloads.

Does immutable work in Safari and WebKit?

Yes. WebKit implemented support for Cache-Control: immutable in Safari 11. Firefox added it in version 49, and Chromium-based browsers (Chrome, Edge, Brave) introduced full support in version 68.

Why does my Nginx server still send ETag headers with immutable?

Nginx includes the ETag header automatically for static files unless you explicitly disable it using etag off;. Leaving the ETag header active is harmless. If a user performs a hard refresh, the browser can still use that ETag to validate the file without breaking anything.

Next Steps for Edge Caching

Now that your origin server serves immutable headers for fingerprinted assets, the next step is ensuring your CDN edge servers mirror this behavior without overloading your origin. Check out our setup guide on how to configure CDN origin shielding to prevent cache stampedes during heavy traffic spikes.

all_in_one_marketing_tool