~/blog/-blog-http-caching-explained-
blog · HTTP

HTTP Caching Explained: Cache-Control, ETags, and Why Your Browser Serves Stale Pages

How HTTP caching headers actually work, what Cache-Control directives do, how ETags and conditional requests save bandwidth, and the mistakes that cause stale deployments.

last updated · June 20, 2026by @vultio

Why HTTP caching exists and what it actually caches

HTTP caching is the mechanism that allows browsers, CDNs, and proxy servers to store responses and serve them without going back to the origin server. Done correctly, it makes your site dramatically faster and reduces server load. Done incorrectly, it serves users outdated content after deployments, leaks private data through shared caches, or caches nothing at all because the headers are missing.

The HTTP spec defines two independent caching mechanisms: freshness(is the cached response still valid without asking the server?) andvalidation (ask the server if the cached version is still current, and only download the body if it changed). Understanding both is the key to configuring caching that is both fast and correct.

Cache-Control: the header that controls everything

Cache-Control is a comma-separated list of directives that tells caches what to do with the response. Most caching configuration comes down to choosing the right combination of these directives.

# Static assets with content-hashed filenames (images, CSS, JS bundles)
# Cache forever in all caches — the filename changes when content changes
Cache-Control: public, max-age=31536000, immutable

# HTML pages — do not cache in CDNs, allow browser to cache briefly
Cache-Control: public, max-age=0, must-revalidate

# API responses — private (per-user), short TTL, must revalidate when stale
Cache-Control: private, max-age=60, must-revalidate

# Sensitive data — absolutely no caching anywhere
Cache-Control: no-store

# Force revalidation before serving from cache (even if fresh)
Cache-Control: no-cache   # confusing name: it means "always check", not "don't cache"

# CDN-specific split: cache at CDN for 1 year, browser for 1 hour
# (supported by Cloudflare, Fastly, Varnish — not in HTTP spec)
Cache-Control: public, max-age=3600, s-maxage=31536000

Key directives explained

max-age=N          → Cache is fresh for N seconds from the response date
s-maxage=N         → Same as max-age but applies only to shared caches (CDNs)
                     Overrides max-age for shared caches if both are present

public             → Any cache (browser, CDN, proxy) may store the response
private            → Only the user's browser cache; CDNs must not cache it

no-cache           → Store the response but always revalidate before serving
                     (does NOT mean "never cache" — the name is misleading)
no-store           → Do not store anywhere — the response cannot be cached at all

must-revalidate    → Once stale, must revalidate before serving (even offline)
immutable          → Tell the browser the content will never change; skip
                     conditional revalidation requests entirely

stale-while-revalidate=N → Serve stale content for N seconds while fetching fresh
                            copy in the background (great for non-critical data)
stale-if-error=N         → Serve stale content for N seconds if the origin is down

ETags and conditional requests: validating without re-downloading

An ETag is a fingerprint of the response body — a hash or version identifier the server includes with the response. When the browser has a cached (but possibly stale) response, it sends a conditional request with the ETag instead of asking for the full response again. The server compares the ETag to the current version and responds with either a 304 Not Modified (the cache is still valid, no body sent) or a 200 with the new content.

# Server sends ETag with the initial response
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a3f2b1c9d4e5"
Cache-Control: private, max-age=300
Content-Length: 1248

{"user": ...}

# 5 minutes later, browser revalidates with the ETag
GET /api/users/42 HTTP/1.1
If-None-Match: "a3f2b1c9d4e5"

# If the resource has NOT changed:
HTTP/1.1 304 Not Modified
ETag: "a3f2b1c9d4e5"
# No body — saves the 1248 bytes of bandwidth

# If the resource HAS changed:
HTTP/1.1 200 OK
ETag: "f9e8d7c6b5a4"   # new ETag
Content-Length: 1392

{"user": ...}           # new body

Last-Modified: the older alternative to ETags

# Server sends Last-Modified timestamp
HTTP/1.1 200 OK
Last-Modified: Fri, 20 Jun 2026 10:00:00 GMT
Cache-Control: public, max-age=3600

# Browser revalidates using the timestamp
GET /api/data HTTP/1.1
If-Modified-Since: Fri, 20 Jun 2026 10:00:00 GMT

# ETag is preferred over Last-Modified because:
# - Timestamps have 1-second precision (two changes in one second look the same)
# - Last-Modified changes when a file is restored from backup (even if identical)
# - ETags are content-based, so they are truly accurate

# Servers can send both; ETags take precedence when both are present

The stale deployment problem and how to solve it

The most common caching mistake: setting long max-age on HTML files. If yourindex.html is cached for an hour and you deploy a new version, users who loaded the page in the last hour get the old HTML, which references old JS and CSS bundles. Even if those bundles are updated, the browser is using the old HTML that references old filenames.

The correct strategy is cache busting through content hashing. Modern bundlers (Vite, webpack, Next.js) include a hash of the file's content in the filename:app.a1b2c3d4.js. The HTML file references the exact hashed filename. When you deploy, new files get new hashes and new names, so caches treat them as entirely new resources. The HTML file itself should never be cached long-term — it is the entry point that always needs to reflect the latest deploy.

# Correct caching strategy for a typical web app

# HTML entry points — always fresh (or very short TTL)
Cache-Control: no-cache   # check server every time, 304 if unchanged

# Hashed JS/CSS bundles (app.a1b2c3d4.js, styles.f5e6d7c8.css)
Cache-Control: public, max-age=31536000, immutable   # cache 1 year

# Images with stable filenames (logo.png, favicon.ico)
Cache-Control: public, max-age=86400   # cache 24 hours

# API responses
Cache-Control: private, no-cache   # always revalidate

# Result: after deployment, users get fresh HTML immediately,
# and the new HTML references new asset filenames that bypass
# the old caches entirely.

Vary: caching by request headers

The Vary header tells caches that the response content might differ based on certain request headers. Without it, a CDN might serve a gzip-compressed response to a client that does not support gzip, or serve the English version of a page to a French user.

# Cache a separate copy per accepted encoding (gzip vs br vs none)
Vary: Accept-Encoding

# Cache a separate copy per language preference
Vary: Accept-Language

# CORS + caching: cache per requesting origin
# (required when dynamically setting Access-Control-Allow-Origin)
Vary: Origin

# Multiple headers (cache separate copy for each combination)
Vary: Accept-Encoding, Accept-Language

# Warning: Vary: * means the response cannot be cached in a shared cache
# Avoid it except when the response genuinely varies in an unspecified way

Debugging cache behaviour

# Check what headers the server is actually sending
curl -I https://example.com/api/data
curl -v https://example.com/styles.css 2>&1 | grep -i cache

# Check if a CDN served the response (not the origin)
# Look for: X-Cache: HIT (Cloudflare, Fastly, etc.)
curl -I https://example.com | grep -i x-cache

# Test conditional request manually
curl -I -H "If-None-Match: \"a3f2b1c9d4e5\"" https://example.com/api/data

# In Chrome DevTools → Network tab:
# - "from memory cache" → served from in-memory browser cache (no request sent)
# - "from disk cache"   → served from disk cache (no request sent)
# - "304"               → conditional request validated; cache still valid
# - Disable cache checkbox → forces all requests to go to the server