304 Not Modified — Cache Serving Stale Content
Symptoms
- After deploying a content update, users still see the old version of the page or API response — clearing the browser cache shows the new content
- Browser DevTools Network tab shows 304 Not Modified responses for resources that should have changed in the latest deployment
- The server's ETag or Last-Modified timestamp did not change even though the underlying content changed — the server incorrectly validates the stale cache
- A CDN or reverse proxy returns 304 from its cache without reaching the origin server, serving content that is hours or days old
- Static assets (JS, CSS) load the old version even after forcing a hard refresh because the file names are unchanged and the old ETag still matches
- Browser DevTools Network tab shows 304 Not Modified responses for resources that should have changed in the latest deployment
- The server's ETag or Last-Modified timestamp did not change even though the underlying content changed — the server incorrectly validates the stale cache
- A CDN or reverse proxy returns 304 from its cache without reaching the origin server, serving content that is hours or days old
- Static assets (JS, CSS) load the old version even after forcing a hard refresh because the file names are unchanged and the old ETag still matches
Root Causes
- CDN or reverse proxy caching the response and not invalidating on deploy — the CDN serves the cached copy and the origin server's updated ETag is never consulted
- ETag generation based on file metadata (inode number, size, modification time) that didn't change even though the file content did — this happens when files are copied rather than moved during deployment
- Cache-Control headers set with a long `max-age` value without proper cache busting — the browser never revalidates until the max-age expires
- `If-None-Match` or `If-Modified-Since` headers matching a stale cache entry because the server's ETag algorithm produces the same hash for different content
- Static files served without content-hash filenames — same filename `app.js` before and after deployment makes the 304 check pass incorrectly
Diagnosis
**Step 1 — Check the ETag before and after deployment**
```bash
# Before deploying:
curl -I https://example.com/static/app.js
# < ETag: "abc123"
# < Cache-Control: max-age=31536000
# After deploying:
curl -I https://example.com/static/app.js
# Still shows: ETag: "abc123" ← ETag didn't change!
# This is the bug — content changed but ETag didn't
```
**Step 2 — Bypass the cache to verify the origin has new content**
```bash
# Cache-Control: no-cache forces revalidation with the origin
curl -H 'Cache-Control: no-cache' -I https://example.com/static/app.js
# Or add a cache-busting query string to bypass CDN:
curl -I 'https://example.com/static/app.js?nocache=1'
# If this shows new content, the CDN cache is stale
```
**Step 3 — Check what ETag the origin generates**
```bash
# Bypass CDN by connecting directly to the origin server
curl -I http://origin-server-ip/static/app.js \
-H 'Host: example.com'
# If origin ETag differs from CDN-cached ETag → CDN is stale
```
**Step 4 — Inspect Nginx ETag configuration**
```bash
# Nginx generates ETag from last modification time and file size by default
# If only file content changed (not mtime/size), ETag stays the same
grep -r 'etag' /etc/nginx/nginx.conf /etc/nginx/sites-enabled/
# etag on; (default)
# For content-based ETags, use a hash of the file content instead
```
**Step 5 — Check CDN cache invalidation on your deployment script**
```bash
# Cloudflare: Purge entire cache or specific URLs
CF_TOKEN='your-api-token'
ZONE_ID='your-zone-id'
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"purge_everything": true}'
```
```bash
# Before deploying:
curl -I https://example.com/static/app.js
# < ETag: "abc123"
# < Cache-Control: max-age=31536000
# After deploying:
curl -I https://example.com/static/app.js
# Still shows: ETag: "abc123" ← ETag didn't change!
# This is the bug — content changed but ETag didn't
```
**Step 2 — Bypass the cache to verify the origin has new content**
```bash
# Cache-Control: no-cache forces revalidation with the origin
curl -H 'Cache-Control: no-cache' -I https://example.com/static/app.js
# Or add a cache-busting query string to bypass CDN:
curl -I 'https://example.com/static/app.js?nocache=1'
# If this shows new content, the CDN cache is stale
```
**Step 3 — Check what ETag the origin generates**
```bash
# Bypass CDN by connecting directly to the origin server
curl -I http://origin-server-ip/static/app.js \
-H 'Host: example.com'
# If origin ETag differs from CDN-cached ETag → CDN is stale
```
**Step 4 — Inspect Nginx ETag configuration**
```bash
# Nginx generates ETag from last modification time and file size by default
# If only file content changed (not mtime/size), ETag stays the same
grep -r 'etag' /etc/nginx/nginx.conf /etc/nginx/sites-enabled/
# etag on; (default)
# For content-based ETags, use a hash of the file content instead
```
**Step 5 — Check CDN cache invalidation on your deployment script**
```bash
# Cloudflare: Purge entire cache or specific URLs
CF_TOKEN='your-api-token'
ZONE_ID='your-zone-id'
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"purge_everything": true}'
```
Resolution
**Fix 1: Content-hash filenames for static assets (permanent solution)**
```python
# Django: Use ManifestStaticFilesStorage to append content hashes
# settings.py
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# Generates: app.abc123.js — changes automatically when content changes
# Cache-Control: max-age=31536000 is safe because filename changes on update
```
**Fix 2: CDN cache purge on deployment**
```bash
# Add to deploy.sh — purge CDN after deploying new code:
purge_cloudflare_cache() {
local zone_id="$1"
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"purge_everything": true}'
}
purge_cloudflare_cache "$ZONE_ID"
```
**Fix 3: Use Cache-Control: no-cache for HTML pages (not assets)**
```nginx
# nginx.conf — different cache policies for HTML vs static assets
location / {
# HTML pages: always revalidate
add_header Cache-Control 'no-cache, must-revalidate';
try_files $uri $uri/ /index.html;
}
location /static/ {
# Static assets with content hashes: cache forever
add_header Cache-Control 'public, max-age=31536000, immutable';
expires 1y;
}
```
**Fix 4: Add Nginx content-based ETag for files that change without name change**
```bash
# If you can't use content-hash filenames, ensure mtime updates on deploy:
# Instead of 'cp file destination', use:
touch /var/www/example.com/static/app.js # Updates mtime → new ETag
# Or script a find + touch in deploy:
find /var/www/example.com/static/ -type f -exec touch {} +
```
```python
# Django: Use ManifestStaticFilesStorage to append content hashes
# settings.py
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# Generates: app.abc123.js — changes automatically when content changes
# Cache-Control: max-age=31536000 is safe because filename changes on update
```
**Fix 2: CDN cache purge on deployment**
```bash
# Add to deploy.sh — purge CDN after deploying new code:
purge_cloudflare_cache() {
local zone_id="$1"
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"purge_everything": true}'
}
purge_cloudflare_cache "$ZONE_ID"
```
**Fix 3: Use Cache-Control: no-cache for HTML pages (not assets)**
```nginx
# nginx.conf — different cache policies for HTML vs static assets
location / {
# HTML pages: always revalidate
add_header Cache-Control 'no-cache, must-revalidate';
try_files $uri $uri/ /index.html;
}
location /static/ {
# Static assets with content hashes: cache forever
add_header Cache-Control 'public, max-age=31536000, immutable';
expires 1y;
}
```
**Fix 4: Add Nginx content-based ETag for files that change without name change**
```bash
# If you can't use content-hash filenames, ensure mtime updates on deploy:
# Instead of 'cp file destination', use:
touch /var/www/example.com/static/app.js # Updates mtime → new ETag
# Or script a find + touch in deploy:
find /var/www/example.com/static/ -type f -exec touch {} +
```
Prevention
- **Use content-hash filenames for all static assets** (CSS, JS, images) — filename changes on content change mean the browser never serves stale cached files and `max-age=forever` caching is safe
- **Automate CDN cache invalidation in every deployment pipeline** — a missed manual purge is the most common cause of users seeing stale content after deployment
- **Set `Cache-Control: no-cache` on HTML pages and API responses** — this allows storage in the cache but forces revalidation on every request, so the browser gets a 304 (fast) or 200 with new content (correct)
- **Smoke test cache headers after every deployment** with `curl -I` to verify that ETags changed and the CDN returns new content rather than a cached version
- **Automate CDN cache invalidation in every deployment pipeline** — a missed manual purge is the most common cause of users seeing stale content after deployment
- **Set `Cache-Control: no-cache` on HTML pages and API responses** — this allows storage in the cache but forces revalidation on every request, so the browser gets a 304 (fast) or 200 with new content (correct)
- **Smoke test cache headers after every deployment** with `curl -I` to verify that ETags changed and the CDN returns new content rather than a cached version