Intermediate 10 min HTTP 304

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

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}'
```

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 {} +
```

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

Related Status Codes

Related Terms