Skip to main content

Cache Control & HTTP Headers for React Deploys

HTTP Cache-Control headers tell browsers, CDNs, and proxies how long to cache your React assets. Set them correctly and your origin server handles 1/100th the traffic; set them wrong and users see stale bundles for days. The key insight: immutable assets (with content hashes) cache forever; HTML never caches. In 2026, proper cache headers reduce origin server load by 75–85% and improve Lighthouse Core Web Vitals by 30% (HTTP Archive, 2026). This article covers cache strategies for static files, HTML, APIs, and how to handle cache invalidation.

I've debugged production incidents where incorrect cache headers served outdated React bundles for weeks, breaking features for users. This guide prevents that by walking through real-world cache rules and how to test them.

Cache-Control header fundamentals

Cache-Control is an HTTP response header that controls caching behavior. Common directives:

DirectiveMeaningExample
max-age=<seconds>Cache for N seconds before revalidatingmax-age=31536000 (1 year)
publicAllow any cache (browser, CDN, proxy) to cachepublic
privateOnly browser cache; exclude CDNsprivate
no-cacheRevalidate with server before using cached copyno-cache
no-storeNever cache; always fetch from serverno-store
immutableAsset never changes; don't revalidateimmutable
must-revalidateIf cached copy expires, revalidate immediatelymust-revalidate
s-maxage=<seconds>Override max-age for shared caches (CDN)s-maxage=3600

For React deployments, you use four patterns:

Pattern 1: Immutable assets (JS, CSS, images with content hashes)

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

Cache for 1 year. The filename includes a content hash (e.g., app-abc123.js), so it never changes. If content changes, a new hash generates a new filename.

Pattern 2: HTML (never cache)

Cache-Control: no-store, no-cache, must-revalidate

Never cache. Always fetch from server to ensure users get the latest index.html with new bundle URLs.

Pattern 3: APIs (cache conditionally)

Cache-Control: public, max-age=60, must-revalidate

Cache for 60 seconds. Good for /api/users or /api/blog/posts that change infrequently.

Pattern 4: Authentication tokens / sensitive data

Cache-Control: private, no-cache, no-store

Never cache; only browser processes know about it.

Setting cache headers in Nginx (production)

Here's a complete Nginx configuration for a React app:

server {
listen 80;
server_name myapp.example.com;

# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}

server {
listen 443 ssl http2;
server_name myapp.example.com;

ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

# Gzip compression
gzip on;
gzip_types text/plain text/css text/javascript application/javascript application/json;
gzip_min_length 1000;

root /var/www/react-build;
index index.html;

# Cache immutable assets (JS, CSS, images, fonts) for 1 year
location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|woff|ttf|otf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options "nosniff";
}

# Cache HTML for 1 hour max, but revalidate
location ~ \.html$ {
expires 1h;
add_header Cache-Control "public, max-age=3600, must-revalidate";
add_header X-Content-Type-Options "nosniff";
}

# Never cache root index.html
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header X-Content-Type-Options "nosniff";
}

# API responses (proxy to backend)
location ~ ^/api/ {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;

# Cache API responses for 5 minutes
expires 5m;
add_header Cache-Control "public, max-age=300, must-revalidate";
}

# Rewrite non-file requests to index.html (React Router)
location / {
try_files $uri $uri/ /index.html;
}

# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;
}

This Nginx config:

  • Compresses assets with gzip (60–80% size reduction).
  • Caches JS/CSS/images for 1 year (immutable).
  • Caches HTML for 1 hour but revalidates on each request.
  • Never caches the root index.html (so users always see the latest React bundle).
  • Caches API responses for 5 minutes (balance between freshness and origin load).
  • Adds security headers (HSTS, X-Frame-Options, CSP).

Deploy to Docker:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.25-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /var/www/react-build
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Validating cache headers

Check headers with curl:

# Check immutable asset cache
curl -I https://myapp.example.com/app-abc123.js
# Output:
# HTTP/2 200
# cache-control: public, max-age=31536000, immutable
# content-type: application/javascript
# Check HTML cache
curl -I https://myapp.example.com/index.html
# Output:
# HTTP/2 200
# cache-control: no-store, no-cache, must-revalidate

In browser DevTools, open Network tab, reload, and check the "Size" column:

  • from memory cache = cached in browser memory.
  • from disk cache = cached on disk (survives browser restart).
  • 200 OK = fetched from server.

After the first page load:

  • Assets (JS, CSS) should show from disk cache (very fast).
  • HTML should show 200 OK or revalidation (to get the latest bundle URLs).

ETags and 304 Not Modified

When HTML has no-cache or must-revalidate, the browser revalidates on every request by sending an If-None-Match header (if ETag exists):

Request:
GET /index.html HTTP/2
If-None-Match: W/"abc123def456"

Response (if unchanged):
HTTP/2 304 Not Modified
ETag: W/"abc123def456"

A 304 Not Modified means the browser's cached copy is still valid; no need to re-download the body (saves bandwidth). Nginx auto-generates ETags for static files.

For APIs, use ETags similarly:

location ~ ^/api/posts$ {
proxy_pass http://backend:5000;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_valid 200 5m;
expires 5m;
add_header Cache-Control "public, max-age=300, must-revalidate";
}

Handling cache invalidation in production

When you deploy a new React build:

  1. The good: Assets with new content hashes (e.g., app-xyz789.js) are new URLs; browsers download them.
  2. The bad: If you deploy without changing index.html, users never fetch new JS/CSS because they're cached with the old hash in index.html.

Example:

  • Old deploy: <script src="/app-abc123.js"></script>
  • New deploy: <script src="/app-xyz789.js"></script>
  • User visits old bookmark: Browser loads cached index.html with the old hash, never fetches new JS.

Solution: Always update index.html on every deploy. Vite and Create React App do this automatically: each npm run build generates a new index.html with new asset hashes.

To verify: run npm run build twice without changing code, then compare index.html:

npm run build
md5 build/index.html
npm run build
md5 build/index.html
# If hashes differ, good (assets regenerated, likely with new hashes)

Cache-Control edge cases

"Browser is loading a stale bundle"

Likely causes:

  1. Asset hash hasn't changed (npm run build didn't re-hash).
  2. index.html is cached by the browser (shouldn't happen with no-cache).
  3. CDN cache hasn't invalidated (manually purge via Cloudflare or AWS).

Debugging:

# Check what the CDN is serving
curl -I https://myapp.example.com/index.html -H "CF-Cache-Status"
# Cloudflare header: CF-Cache-Status: HIT means cached, MISS means fresh

API returning stale data

If /api/posts returns cached data, clear the cache:

# Cloudflare: purge cache for specific path
curl -X POST "https://api.cloudflare.com/client/v4/zones/<zone-id>/purge_cache" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"files": ["https://myapp.example.com/api/posts"]}'

# AWS CloudFront: invalidate path
aws cloudfront create-invalidation \
--distribution-id EABCDEF12345 \
--paths "/api/posts"

User sees old page after deploy

Ensure:

  1. HTML has no-cache or no-store (never cached for more than a few seconds).
  2. On deploy, CDN cache is invalidated or expires naturally.
  3. Browser cache is cleared (Ctrl+Shift+Delete) or hard-refresh (Ctrl+Shift+R).

Cache-Control for different React patterns

PatternCache-Control
Single-page app (Vite/CRA)HTML: no-cache; assets: immutable for 1 year
Server-side rendered (Next.js)HTML: no-cache, s-maxage=0 (CDN never caches); API: max-age=300
Static site generator (Astro)All files: immutable for 1 year; set new hashes per deploy
Hybrid (ISR / incremental static)HTML: max-age=60, s-maxage=3600 (browser: 1 min, CDN: 1 hour); rebuild on-demand

Key Takeaways

  • Immutable assets (content-hashed JS, CSS, images) cache for 1 year with max-age=31536000, immutable.
  • HTML never caches: no-cache, no-store, must-revalidate.
  • Set public for CDN-cacheable responses; private for browser-only.
  • Use ETags and 304 Not Modified to save bandwidth on revalidation requests.
  • Always update index.html on deploy so browsers fetch new bundle references.

Frequently Asked Questions

Should I cache API responses?

Yes, with a short TTL (5–60 minutes). Cache GET requests to /api/posts, /api/users/profile that change infrequently. Never cache POST/PUT/DELETE (they modify state). Set Cache-Control: public, max-age=300, must-revalidate.

What if a user visits with an old bookmark (old URL)?

If the old asset URL (e.g., app-abc123.js) is immutable-cached, the browser serves it instantly. If it's been deleted from your origin, the user gets 404. To avoid this, keep old bundles on the origin for 30 days, then remove them.

How do I cache API responses differently for authenticated vs anonymous users?

Use Cache-Control: private for authenticated responses (only browser caches, not CDN). For anonymous, use public (CDN caches, visible to all). Or use a separate endpoint (/api/public/posts vs /api/user/feed) with different cache rules.

Can I cache responses based on URL query parameters?

Yes, but be careful. By default, ?v=1 and ?v=2 are different URLs and cached separately. If query params are tracking IDs (analytics), they create separate cache entries (wasteful). Use Cache-Control: no-cache for those, or strip query params before caching (proxy_cache_key $scheme$request_method$host$uri; ignores query string).

How often should I update cache TTLs?

Review cache strategy:

  • Every 3 months: adjust API cache TTL based on origin load (if origin is stressed, increase TTL).
  • On major deployment: verify HTML isn't cached, assets have new hashes.
  • After outage: confirm cache didn't serve stale responses (e.g., if database was offline, cached 500 error shouldn't have been served for 5 minutes).

Further Reading