NGINX

NGINX Browser Caching for Static Files: Cache-Control, Expires & Cache Busting

by ,


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth yum upgrades for production use, this is the repository for you.
Active subscription is required.

NGINX browser caching is one of the fastest wins for website performance. When configured correctly, moreover, it eliminates repeat downloads of CSS, JavaScript, images, and fonts—dramatically improving page load times and reducing server bandwidth.

However, aggressive browser caching without proper cache busting can break your site after deployments. Users will see old CSS with new HTML, leading to broken layouts and frustrating bugs.

Specifically, this comprehensive guide shows you how to configure NGINX browser caching the right way. Mastering NGINX browser caching is essential for modern web performance optimization. You’ll learn the mechanics of Cache-Control and Expires headers, master cache-busting patterns, and deploy production-ready NGINX configurations that you can test and verify.

By the end, you’ll have:

  • Deep understanding of how NGINX cache headers work
  • Multiple cache-busting strategies with pros and cons
  • Copy/paste NGINX configurations for every scenario
  • Performance optimizations beyond basic caching
  • Verification commands using nginx -t and curl -I
  • Optional one-line immutable caching with GetPageSpeed NGINX Extras

NGINX browser caching diagram showing Cache-Control and Expires headers workflow

What Browser Caching Really Means

When a browser downloads a static file like app.css, it stores both the file content and the caching rules. These rules come from HTTP response headers sent by your NGINX server.

The most important header is Cache-Control. Essentially, it tells the browser exactly how long to reuse the cached file without asking the server again.

Example headers:

Cache-Control: max-age=31536000, immutable
Expires: Thu, 31 Dec 2037 23:55:55 GMT

What these mean:

  • max-age=31536000 — Cache this file for one year (31,536,000 seconds)
  • immutable — This URL will never change; don’t bother revalidating
  • Expires — Legacy header for older browsers; sets an absolute expiration date

Critical misconception to avoid: NGINX doesn’t “cache files in the browser.” NGINX sends headers that instruct the browser how to cache. Ultimately, the browser is in control.

How Cache Validation Works

When max-age expires, the browser can revalidate using:

  • ETag: A hash of the file content
  • Last-Modified: Timestamp of when the file last changed

The browser sends these in subsequent requests:

If-None-Match: "5d8c72a5-4c3"
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

If the file hasn’t changed, NGINX responds with 304 Not Modified (no body, saving bandwidth). If it has changed, NGINX sends 200 OK with the new content.

The Golden Rule: Cache Busting First

Here’s the problem: If you cache /assets/app.css for one year and then deploy new CSS to that same URL, many users will still see the old version for up to a year.

The solution: Consequently, you must change the URL when the content changes. In other words, this is called cache busting.

The golden rule:

Only set long cache durations (months or years) for URLs that change when their content changes.

This means you need a cache-busting strategy before you enable aggressive caching. Effective NGINX browser caching requires proper cache busting to work correctly.

Understanding NGINX Browser Caching: Cache-Control and Expires Headers

Let’s dive deeper into these headers and how NGINX generates them.

Cache-Control Directives

Cache-Control is the modern, flexible standard. It supports multiple directives:

Directive Meaning
max-age=N Cache for N seconds
public Can be cached by browsers and CDNs
private Only cache in the user’s browser, not CDNs
no-cache Store the file, but revalidate before reuse
no-store Don’t cache at all (for sensitive data)
must-revalidate After expiration, must revalidate (can’t serve stale)
immutable File will never change; skip revalidation
stale-while-revalidate=N Serve stale content while revalidating in background

The Expires Header

Expires is the older standard (HTTP/1.0). It sets an absolute date/time:

Expires: Thu, 31 Dec 2037 23:55:55 GMT

Modern browsers prefer Cache-Control: max-age, but Expires provides a fallback for ancient clients.

How NGINX’s expires Directive Works

Notably, the NGINX expires directive (documented in the official NGINX headers module) is convenient because it sets both Expires and Cache-Control: max-age with one line.

Syntax:

expires [modified] time;
expires epoch | max | off;

Examples:

expires 1y;              # Sets max-age=31536000 (1 year)
expires 24h;             # Sets max-age=86400 (24 hours)
expires modified +24h;   # Expiration = file mtime + 24 hours
expires @15h;            # Expire at 3 PM daily
expires -1;              # Sets Cache-Control: no-cache
expires epoch;           # Sets Expires to Jan 1, 1970 (forces revalidation)
expires max;             # Sets Expires to Dec 31, 2037 and max-age to 10 years
expires off;             # Don't add/modify headers (default)

Important: When you use expires 1y, NGINX automatically generates:

Expires: [current time + 1 year]
Cache-Control: max-age=31536000

You can then use add_header to append additional directives like immutable.

Here’s a battle-tested policy that balances performance and safety:

1. NGINX Browser Caching for Versioned Static Assets (Cache Busted)

URLs: /assets/app.a3f2c9.css, /js/bundle.12345.js

Policy:

expires 1y;
add_header Cache-Control "public, immutable" always;

Why:
– The URL changes when content changes
– Therefore, safe to cache for the maximum recommended duration (1 year)
immutable prevents unnecessary revalidation
public allows Second, CDN caching

2. NGINX Browser Caching for Non-Versioned Static Assets

URLs: /images/logo.png, /favicon.ico

Policy:

expires 1h;
add_header Cache-Control "public, must-revalidate" always;

Why:
– The URL doesn’t change, so use a short cache
– Additionally, browsers will revalidate with If-Modified-Since/If-None-Match
– Furthermore, this still gets 304 responses (fast and bandwidth-efficient)

3. NGINX Browser Caching for HTML Documents

URLs: /, /about/, /products/

Policy:

add_header Cache-Control "no-cache" always;

Why:
– Moreover, HTML often references other assets; you want the latest version
no-cache means “cache it, but always revalidate”
– As a result, you get fast 304 responses for unchanged HTML
– Consequently, this ensures users get new content immediately after deploy

NGINX Browser Caching: Cache-Busting Strategies Compared

Overall, there are three main strategies. Here’s how they compare:

Strategy 1: Content Hash in Filename for NGINX Browser Caching (Best)

Example: app.a3f2c9.css, bundle.7f8e1d.js

How it works: Your build tool (Webpack, Vite, Rollup) generates filenames with a hash of the file content.

Pros:
– First and foremost, automatic cache busting—hash changes when content changes
– Furthermore, no server-side rewriting needed
– In addition, it works perfectly with CDNs
– Indeed, most common in modern frameworks

Cons:
– However, it requires build tooling
– Every deployment needs updated HTML references

NGINX config: Simple pattern matching (no rewriting needed)

Strategy 2: NGINX Browser Caching with Version/Timestamp in URL

Example: Request /assets/app.1705420000.css but file is /assets/app.css

How it works: HTML references versioned URLs, but NGINX rewrites them to the actual unversioned file.

Pros:
– Additionally, file paths stay simple on disk
– Easy to manage during deployment
– Particularly, good for legacy applications without build tools

Cons:
– On the other hand, it requires NGINX rewrite rules
– Slightly more complex configuration
– Must synchronize version number across all asset references

NGINX config: Uses try_files with a fallback location that strips the version

Strategy 3: Query String Versioning for Browser Caching

Example: /assets/app.css?v=1705420000

How it works: Append a version parameter to the URL.

Pros:
– First, it is extremely simple to implement
– No build tools or rewrites needed
– WordPress uses this by default (style.css?ver=6.7.1)

Cons:
– Nevertheless, some CDNs ignore query strings by default
– Less reliable than filename-based versioning
– Can be harder to reason about in caching policies

Not recommended for new projects, but works acceptably if you’re stuck with it.

NGINX Browser Caching Config: Hashed Filenames

In summary, this is the simplest and most robust setup. Your build tool outputs files like app.3f2c9a1c.css, and NGINX serves them with long cache headers.

NGINX Browser Caching Configuration

# Match files with a hash in the filename
# Examples: app.3f2c9a1c.css, bundle.a1b2c3d4e5f6.js, logo.8f7e6d5c.png
#
# IMPORTANT: The regex is quoted because it contains {8,}.
# Without quotes, NGINX treats { as a config token and the regex breaks.
location ~* "\.([0-9a-f]{8,})\.(css|js|mjs|map|json|png|jpg|jpeg|gif|webp|avif|svg|ico|woff2?|ttf|eot|otf)$" {
    # Disable access logging to save disk I/O (see section below)
    access_log off;

    # Cache for 1 year (max recommended by RFC)
    expires 1y;

    # Add immutable directive (prevents unnecessary revalidation)
    add_header Cache-Control "public, immutable" always;

    # Optional: Add CORS headers if serving fonts/assets cross-origin
    # add_header Access-Control-Allow-Origin "*" always;
}

Why This NGINX Caching Works

  1. Regex pattern: \.([0-9a-f]{8,})\. matches a hash of at least 8 hex characters before the file extension
  2. Quoting: NGINX requires quotes around regexes containing { to avoid syntax errors
  3. File extensions: Covers all common static assets (CSS, JS, images, fonts)
  4. expires 1y: Sets both Expires header and Cache-Control: max-age=31536000
  5. add_header ... always: The always ensures the header is added even on error responses

Testing NGINX Browser Caching

Subsequently, after reloading NGINX, test with:

curl -I https://example.com/assets/app.3f2c9a1c.css

Ideally, you should see:

HTTP/1.1 200 OK
Cache-Control: public, immutable, max-age=31536000
Expires: Fri, 18 Jan 2026 12:00:00 GMT

NGINX Browser Caching Config: Versioned URLs with URL Rewriting

This strategy is ideal when you can’t modify filenames on disk (legacy apps, simple deployment processes) but can control the HTML output.

How NGINX Browser Caching Works

  1. On disk: Files are unversioned (/assets/app.css, /assets/app.js)
  2. In HTML: References are versioned (<link href="/assets/app.1705420000.css">)
  3. NGINX rewrites: /assets/app.1705420000.css/assets/app.css
  4. Browser sees: Versioned URL with long cache headers

NGINX Browser Caching Configuration (Digit-Based Versioning)

# Match versioned CSS/JS: app.1705420000.css, bundle.12345.js
# IMPORTANT: Place this ABOVE any non-versioned .css/.js location blocks
# (NGINX uses first matching location)
location ~* \.(\d+)\.(css|js)$ {
    access_log off;

    # Long cache for versioned URLs
    expires 1y;
    add_header Cache-Control "public, immutable" always;

    # Try versioned file first, fallback to stripping version
    try_files $uri @css_js_strip_version;
}

# Fallback: strip version and serve unversioned file
location @css_js_strip_version {
    # Rewrite: /assets/app.1705420000.css -> /assets/app.css
    rewrite ^(.+)\.(\d+)\.(css|js)$ $1.$3 break;

    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable" always;
}

Why try_files Before Rewrite?

Notably, this pattern is efficient because:

  1. First, if versioned file exists (e.g., you also deploy with versions), NGINX serves it directly
  2. If versioned file doesn’t exist, NGINX falls back to the named location
  3. Named location strips version and serves the unversioned file
  4. Result: Finally, browser caches the versioned URL for a year, but deployment is simple

Alternative NGINX Caching: Hash-Based Versioning

If you use hashes instead of timestamps (app.a3f2c9.css), adjust the regex:

location ~* "\.([0-9a-f]{6,})\.(css|js)$" {
    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable" always;
    try_files $uri @css_js_strip_version;
}

location @css_js_strip_version {
    rewrite ^(.+)\.([0-9a-f]{6,})\.(css|js)$ $1.$3 break;
    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable" always;
}

Unified Location for Both Versioned and Non-Versioned

If you want one location that handles both scenarios (short cache for non-versioned, long cache for versioned):

location ~* \.(css|js)$ {
    access_log off;

    # Default: short cache for non-versioned URLs
    expires 1h;
    add_header Cache-Control "public, must-revalidate" always;

    # Try direct file, then try stripping version
    try_files $uri @css_js_strip_version;
}

location @css_js_strip_version {
    # Strip version: app.12345.css -> app.css
    rewrite ^(.+)\.(\d+)\.(css|js)$ $1.$3 break;
    # Long cache for versioned URLs
    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable" always;
}

Caveat: This gives short cache to direct requests for app.css, but long cache to versioned requests like app.12345.css (which rewrite to the same file). This works, but can be confusing if you request both URLs.

NGINX Browser Caching for HTML and Non-Versioned Assets

Obviously, not everything can be cache busted. HTML documents and some static assets need different policies.

HTML: Always Revalidate

HTML documents often reference other versioned assets. Therefore, you want users to get the latest HTML immediately so they load the latest CSS/JS references.

Policy: no-cache (store but revalidate)

location / {
    # HTML files (index.html, about.html, etc.)
    add_header Cache-Control "no-cache" always;

    # Optional: Enable ETag for efficient revalidation
    etag on;
}

What this does:

  • Initially, browser caches the HTML
  • On next visit, browser sends If-None-Match (ETag) or If-Modified-Since
  • If unchanged, then NGINX responds 304 Not Modified (very fast)
  • If changed, then NGINX sends new HTML with 200 OK

Why not no-store? no-store prevents caching entirely, forcing a full download every time. no-cache is faster because it allows 304 responses.

Non-Versioned Static Assets: Short Cache

For images, fonts, or other files that don’t change often but aren’t versioned:

location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico|woff2?|ttf|eot)$ {
    access_log off;

    # Cache for 1 hour, revalidate after
    expires 1h;
    add_header Cache-Control "public, must-revalidate" always;
}

Alternative: Longer cache for images

If your images rarely change, you can use a longer duration:

location ~* \.(png|jpg|jpeg|gif|webp|avif|svg)$ {
    access_log off;
    expires 30d;
    add_header Cache-Control "public, must-revalidate" always;
}

Just remember: if you change the image at the same URL, users may see the old version for up to 30 days (unless they force-refresh).

Performance Optimizations Beyond NGINX Browser Caching

Browser caching reduces repeat requests, but you also want the first request to be fast. Fortunately, NGINX has several options to optimize static file delivery:

Enable Efficient File Transmission

# In http { ... } context
sendfile on;           # Use kernel sendfile() for zero-copy file transmission
tcp_nopush on;         # Send HTTP response headers in one packet with file start
tcp_nodelay on;        # Disable Nagle's algorithm for low-latency responses

What these do:

  • sendfile on: This bypasses userspace copying; kernel sends file data directly to the socket (faster, less CPU)
  • tcp_nopush on: Buffers headers and the start of the file into a single TCP packet (reduces overhead)
  • tcp_nodelay on: Sends small packets immediately instead of waiting to batch them (lower latency)

Collectively, these three together significantly improve static file performance.

Cache File Metadata (open_file_cache)

Additionally, NGINX can cache file metadata (file descriptors, sizes, modification times) to avoid repeated syscalls:

# In http { ... } context
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

What these mean:

  • max=10000: Cache metadata for up to 10,000 files
  • inactive=30s: Remove cached metadata if not accessed for 30 seconds
  • valid=60s: Revalidate cached metadata every 60 seconds
  • min_uses=2: Only cache files accessed at least twice (avoid cache pollution)
  • errors=on: Cache “file not found” errors too (reduces 404 overhead)

Impact: On high-traffic sites serving thousands of static files, this can reduce disk I/O and CPU by 10-20%.

Gzip Compression for Text Files

Similarly, NGINX can compress CSS, JavaScript, and other text files on the fly:

# In http { ... } context
gzip on;
gzip_vary on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 6;

Important: Serve pre-compressed .gz files if available:

gzip_static on;  # Serve .css.gz if it exists instead of compressing on-the-fly

This requires your build process to generate .gz files during deployment, but it’s more efficient than runtime compression.

Disk I/O Optimization with access_log off

By default, every request NGINX handles gets logged by default:

192.168.1.100 - - [20/Jan/2026:12:34:56 +0000] "GET /assets/app.css HTTP/1.1" 200 4096

The problem: On high-traffic sites, logging every CSS, JS, and image request can cause significant disk I/O overhead. SSDs can handle it, but However, HDDs struggle, and even SSDs have finite write endurance.

The solution: Therefore, disable access logging for static assets:

location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ {
    access_log off;  # Don't log these requests
    # ... rest of config ...
}

When to use access_log off:

  • High-traffic sites (thousands of requests/second)
  • Static assets that don’t need individual request tracking
  • You’re using a CDN (traffic is offloaded anyway)
  • Disk I/O is a bottleneck

When NOT to disable:

  • You need detailed analytics on static asset usage
  • Debugging performance issues (temporarily enable to see what’s being requested)
  • Security monitoring (detecting suspicious patterns)

Compromise: Keep access logs for HTML, disable for static assets:

# HTML: keep logging
location / {
    access_log /var/log/nginx/access.log combined;
    # ...
}

# Static assets: no logging
location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ {
    access_log off;
    # ...
}

The Immutable Module: One-Line Perfect Caching

If you’re using GetPageSpeed NGINX Extras, you can enable an optional module that implements perfect caching in one line.

Installation

On RHEL/CentOS/AlmaLinux/RockyLinux 8+:

sudo dnf install nginx-module-immutable

On RHEL/CentOS 7:

sudo yum install nginx-module-immutable

Enable the NGINX Browser Caching Module

Add to the top of /etc/nginx/nginx.conf (outside the http block):

load_module modules/ngx_http_immutable_module.so;

NGINX Browser Caching Configuration

location /static/ {
    immutable on;
}

That’s it. Automatically, the module automatically generates:

Cache-Control: public, max-age=31536000, stale-while-revalidate=31536000, immutable
Expires: Thu, 31 Dec 2037 23:55:55 GMT

What You Get

  • max-age=31536000: 1-year cache duration (RFC recommended maximum)
  • immutable: Consequently, prevents unnecessary revalidation for cache-busted resources
  • stale-while-revalidate=31536000: Allows serving stale content while revalidating (improves cache hit rates)
  • public: Allows CDN caching
  • Perfect Expires header: Set to the RFC-recommended far-future date (2037)

Why It’s Better Than expires max;

NGINX’s built-in expires max; sets:

Cache-Control: max-age=315360000   # 10 years (exceeds RFC recommendation)
Expires: Thu, 31 Dec 2037 23:55:55 GMT

Overall, the immutable module is better because:

  1. RFC-compliant: First, it uses 1 year instead of 10 years
  2. Adds immutable: Requires manual add_header with expires
  3. Adds stale-while-revalidate: Thus improves cache behavior
  4. One line: No need to combine expires + add_header

Ideal Use Cases

  • Frameworks with hash-based cache busting (Webpack, Vite, Next.js, etc.)
  • Magento 2 (which uses hash-based asset URLs)
  • Any application where asset URLs change when content changes

Verifying Your NGINX Browser Caching Configuration

Importantly, after configuring NGINX, always verify your setup.

Step 1: Test Configuration Syntax

sudo nginx -t

You should see:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

However, if you see errors, do not reload NGINX. then fix the errors first.

Step 2: Reload NGINX

sudo systemctl reload nginx

Or:

sudo nginx -s reload

Step 3: Verify Headers with curl

Test a versioned asset:

curl -I https://example.com/assets/app.a3f2c9.css

Expected output:

HTTP/1.1 200 OK
Server: nginx
Content-Type: text/css
Cache-Control: public, immutable, max-age=31536000
Expires: Fri, 18 Jan 2027 12:00:00 GMT

Test HTML:

curl -I https://example.com/

Expected output:

HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html
Cache-Control: no-cache

Test a non-versioned asset:

curl -I https://example.com/favicon.ico

Expected output:

HTTP/1.1 200 OK
Server: nginx
Content-Type: image/x-icon
Cache-Control: public, must-revalidate, max-age=3600
Expires: [1 hour from now]

Step 4: Test in Browser DevTools

  1. Open DevTools (F12)
  2. Go to Network tab
  3. Reload the page
  4. Click on a CSS/JS file
  5. Check the “Headers” section

Look for:

  • Response Headers: Cache-Control, Expires
  • Status Code on reload: 200 (first visit) or 304 (revalidated) or (disk cache) (served from cache)

Advanced NGINX Browser Caching: CDN Integration

When using a CDN (Cloudflare, Fastly, AWS CloudFront), your NGINX configuration still matters because the CDN respects your origin’s caching headers.

How CDN Caching Works

  1. First request: Initially, CDN requests file from NGINX origin
  2. NGINX responds: Subsequently, with Cache-Control and Expires headers
  3. CDN caches: Then, based on your headers (or CDN-specific rules)
  4. Subsequent requests: Finally, CDN serves from cache (Thus, NGINX not hit)

Best Practices for CDN + NGINX

1. Use public in Cache-Control:

add_header Cache-Control "public, max-age=31536000, immutable" always;

public explicitly allows shared caches (CDNs) to store the content.

2. Set consistent headers:

Therefore, make sure your max-age matches your caching intent. Some CDNs ignore Expires and only respect Cache-Control.

3. Therefore, use versioned URLs:

Indeed, CDN caching makes cache busting even more critical. Otherwise, a CDN might cache the wrong version across millions of users.

4. Configure CDN cache duration separately:

Fortunately, many CDNs let you override origin headers. For example, Cloudflare’s Edge Cache TTL or AWS CloudFront’s Min/Max TTL. Therefore, set these based on your needs, but always use versioned URLs for long durations.

Advanced: HTTP/2 and HTTP/3 Considerations

Modern HTTP protocols affect caching behavior, though the headers remain the same.

HTTP/2 Benefits

  • Multiplexing: One connection handles many requests (reduces overhead)
  • Header compression: HPACK reduces header size (faster for repeated headers)
  • Server push: (Mostly deprecated, but) could preemptively send cached assets

NGINX HTTP/2 setup:

server {
    listen 443 ssl http2;
    # ... rest of config ...
}

Nevertheless, caching headers work identically, but multiplexing makes cache misses less expensive.

HTTP/3 (QUIC)

HTTP/3 uses QUIC (UDP-based) instead of TCP. Benefits:

  • Faster connection establishment: 0-RTT for returning visitors
  • Better handling of packet loss: Thus, no head-of-line blocking

NGINX HTTP/3 setup (requires recent NGINX with QUIC support or OpenResty):

server {
    listen 443 ssl http2;
    listen 443 quic reuseport;
    http3 on;
    # ... rest of config ...
}

Again, caching headers are the same. In summary, Essentially, HTTP/3 just makes delivery faster.

Troubleshooting NGINX Browser Caching Issues

Problem: Headers Not Appearing

Symptom: curl -I doesn’t show Cache-Control or Expires.

Causes:

  1. First, NGINX config not reloaded: Therefore, run sudo nginx -s reload
  2. Second, location block mismatch: Your regex doesn’t match the URL
  3. Third, headers only on 200 responses: Add always to add_header:
add_header Cache-Control "public, immutable" always;

Without always, headers only appear on successful responses (200, 201, 204, etc.), not on redirects or errors.

Problem: Versioned URLs Return 404

Symptom: /assets/app.12345.css returns 404 even though /assets/app.css exists.

Causes:

  1. Rewrite rule not working: Therefore, check that the named location @css_js_strip_version is defined
  2. Second, location order wrong: Thus, versioned location must come before non-versioned
  3. Regex doesn’t match: Therefore, test with location ~* \.(\d+)\.css$ and verify with a simple URL

Debug: Add to rewrite location:

location @css_js_strip_version {
    error_log /var/log/nginx/rewrite.log debug;
    rewrite ^(.+)\.(\d+)\.(css|js)$ $1.$3 break;
}

Then check /var/log/nginx/rewrite.log.

Problem: Caching Too Aggressive (Can’t Update Files)

Symptom: Deployed new CSS, but users still see old version.

Causes:

  1. First, no cache busting: URL didn’t change, so browsers kept the cached version
  2. CDN caching: Even if you updated origin, CDN still serves old version
  3. Third, service worker caching: Progressive Web Apps can cache aggressively

Solutions:

  • First, implement cache busting (hash in filename or versioned URLs)
  • Second, purge CDN cache after deployment
  • Third, update service worker to invalidate caches on version change

Problem: NGINX Crashes or 502 Errors After Config Change

Symptom: Site goes down after nginx -s reload.

Causes:

  1. First, syntax error: Therefore, run nginx -t first
  2. Second, regex error: Unquoted { or } in location regex
  3. Third, module not loaded: Trying to use immutable without loading the module

Recovery:

# Revert config
sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf

# Or restore from git
cd /etc/nginx && git checkout nginx.conf

# Reload with known-good config
sudo nginx -s reload

Common Mistakes to Avoid

  1. Caching non-versioned URLs for a year

    Problem: When you deploy, users see old CSS/JS for months.

    Fix: Instead, only long-cache versioned URLs.

  • Forgetting cache busting entirely

    Problem: You set expires 1y; on /assets/app.css, then wonder why deploys don’t work.

    Fix: Instead, implement hash-based or version-based cache busting first.

  • Putting versioned location below non-versioned

    Problem: NGINX uses the first matching location, so your versioned regex never matches.

    Fix: Importantly, order matters—place more specific locations first:

    location ~* \.(\d+)\.(css|js)$ { }    # Versioned - first
    location ~* \.(css|js)$ { }           # Non-versioned - second
    
  • Not using always with add_header

    Problem: Headers don’t appear on 304 or error responses.

    Fix: add_header Cache-Control "..." always;

  • Skipping verification with curl -I

    Problem: You assume it works, but headers aren’t being sent.

    Fix: Instead, always verify with curl -I after changing config.

  • Using expires max; without understanding it

    Problem: Sets 10-year cache (exceeds RFC recommendation) and doesn’t add immutable.

    Fix: Instead, use expires 1y; + add_header Cache-Control "public, immutable" always; or use the immutable module.

  • Disabling ETag when using no-cache

    Problem: Without ETag, browsers can’t revalidate efficiently.

    Fix: Instead, keep etag on; (NGINX default) for HTML and non-versioned assets.

  • Not compressing text assets

    Problem: CSS/JS files are 3-5x larger than necessary.

    Fix: Instead, enable gzip on; or use pre-compressed .gz files with gzip_static on;.

  • Forgetting to reload NGINX after changes

    Problem: Config changes don’t take effect.

    Fix: sudo nginx -s reload (or systemctl reload nginx)

  • Using query strings instead of filename-based versioning

    Problem: Unfortunately, some CDNs ignore query strings; less reliable.

    Fix: Prefer /app.a3f2c9.css over /app.css?v=123.

  • FAQ

    Should I use Expires or Cache-Control?

    Use Cache-Control as the primary mechanism. since modern browsers prefer it, and it’s more flexible.

    However, NGINX’s expires directive is convenient because it sets both Expires and Cache-Control: max-age with one line.

    Best practice: Therefore, use expires for the duration, then add_header for additional directives like immutable.

    Is immutable required?

    No, but it’s helpful. Specifically, without it, browsers may revalidate cached assets during back/forward navigation. Conversely, with immutable, browsers skip unnecessary revalidation when the URL is known to be cache-busted.

    Impact: This means slightly faster back/forward navigation, fewer conditional requests to NGINX.

    What’s the difference between no-cache and no-store?

    • no-cache: Specifically, cache the file, but revalidate before reuse (allows 304 responses)
    • no-store: Don’t cache at all (always re-download)

    Generally, for HTML, use no-cache (faster due to 304 responses).

    Instead, use no-store only for sensitive data (banking pages, personal info).

    Why 1 year instead of 10 years?

    The HTTP/1.1 spec (RFC 7234) recommends a maximum of 1 year for far-future expiration. NGINX’s expires max; uses 10 years, which exceeds the recommendation.

    While most browsers tolerate it, sticking to 1 year is safer and more standards-compliant.

    How does WordPress fit into NGINX browser caching?

    WordPress often serves assets from predictable paths like /wp-content/themes/ and /wp-content/plugins/.

    Nevertheless, the same rule applies: only long-cache versioned URLs.

    Note that WordPress core and many plugins use query-string versioning (style.css?ver=6.7.1). This works, but filename-based versioning is more reliable with CDNs.

    NGINX config for WordPress:

    # Versioned static assets (hash or version in filename)
    location ~* "\.([0-9a-f]{8,})\.(css|js|png|jpg|jpeg|gif|webp|svg|woff2?)$" {
        access_log off;
        expires 1y;
        add_header Cache-Control "public, immutable" always;
    }
    
    # Non-versioned assets (short cache)
    location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ {
        access_log off;
        expires 1h;
        add_header Cache-Control "public, must-revalidate" always;
    }
    

    Can I cache API responses?

    Yes, but carefully. since API responses often change, so:

    • GET requests for static data: Therefore, cache with max-age (e.g., 5 minutes)
    • GET requests for user-specific data: Instead, use private instead of public
    • POST/PUT/DELETE: conversely, never cache

    Example:

    location /api/posts {
        add_header Cache-Control "public, max-age=300" always;  # 5 minutes
    }
    
    location /api/user {
        add_header Cache-Control "private, max-age=60" always;  # 1 minute, browser only
    }
    

    How do I purge cached files?

    In browsers: Hard refresh (Ctrl+Shift+R or Cmd+Shift+R).

    In CDNs: Use your CDN’s purge/invalidation feature (Cloudflare, Fastly, AWS CloudFront).

    In NGINX: NGINX doesn’t cache by default (this guide is about browser/CDN caching, not NGINX proxy caching). If you’re using proxy_cache, use the proxy_cache_purge module.

    Does this work with SSL/HTTPS?

    Yes. Importantly, caching headers work identically over HTTP and HTTPS.

    However, browsers cache HTTP and HTTPS separately. Therefore, if you migrate from HTTP to HTTPS, users will re-download assets (new cache partition).

    Should I cache differently for mobile vs desktop?

    Usually no. since modern responsive designs serve the same assets to all devices.

    Exception: However, if you serve different images/CSS based on user-agent, use Vary: User-Agent header:

    add_header Vary "User-Agent" always;
    

    But this complicates CDN caching. Therefore, better to use responsive images (srcset) and media queries.

    How do I test cache-busting before deploying?

    Local test:

    1. First, run your build: npm run build
    2. Second, check output filenames: ls dist/assets/
    3. Third, verify hashes changed: Compare with previous build
    4. Fourth, test locally: Start a local NGINX or dev server and check headers

    Staging test:

    1. First, deploy to staging environment
    2. Second, use curl -I to verify headers
    3. Third, open DevTools and verify browser caching
    4. Fourth, deploy a change and verify new hashes appear

    Conclusion

    In conclusion, NGINX browser caching is a powerful performance optimization technique. When implemented correctly, NGINX browser caching can dramatically improve your website speed, but only when combined with proper cache busting. To summarize, the key principles:

    1. First, long cache only for versioned URLs (hash or version in filename/URL)
    2. Second, short cache or no-cache for non-versioned content
    3. Third, use Cache-Control with max-age and immutable
    4. Fourth, disable access logging for static assets to save disk I/O
    5. Fifth, test with nginx -t and curl -I before deploying
    6. Sixth, consider the immutable module for one-line perfect caching

    Overall, implement these patterns, test them, and deploy with confidence.

    As a result, your site will load faster, use less bandwidth, and provide a better user experience.

    Related reading:

    D

    Danila Vershinin

    Founder & Lead Engineer

    NGINX configuration and optimizationLinux system administrationWeb performance engineering

    10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    You may use these HTML tags and attributes:

    <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

    This site uses Akismet to reduce spam. Learn how your comment data is processed.