Skip to main content

HTTP Caching

The fastest request is the one never sent, and the second-fastest is the one answered in a few bytes instead of a full body. HTTP caching gives every layer between the origin server and the browser - reverse proxy, CDN edge, browser cache - rules for storing a response and reusing it later, or for cheaply confirming it is still valid.

Where caches sit

A single response can be cached at several points on its way to the user:

CacheLivesShared across users?
Browser cacheOn the client's disk/memoryNo - one user
CDN edgePoints of presence near usersYes - all users at that PoP
Reverse proxyIn front of the origin (e.g. Varnish, nginx)Yes - all users hitting that origin

Response headers decide which of these are even allowed to store a copy.

Cache-Control: the core directive

Cache-Control is a comma-separated list of directives set by the server (and sometimes the client):

DirectiveMeaning
max-age=NFresh for N seconds from when the response was generated
no-cacheMay be cached, but must revalidate with the origin before every reuse
no-storeMust not be cached anywhere - not even briefly, not even the body
privateOnly the end-user's browser may cache it, not a shared cache/CDN
publicAny cache, including shared ones, may store it
immutableNever changes for the life of this URL - skip revalidation entirely, even on refresh
stale-while-revalidate=NServe the stale copy immediately, then refetch in the background for N seconds
caution

no-cache does not mean "don't cache" - that is no-store. no-cache means "cache it, but always ask the origin whether it's still good before handing it to the user." The naming is a classic HTTP gotcha.

A typical split:

  • Fingerprinted static assets (app.3f9a1c.js): Cache-Control: public, max-age=31536000, immutable - a year, never revalidated, because a content change means a new URL.
  • An HTML page: Cache-Control: no-cache - always revalidate, so users see new content the moment it ships, while still avoiding a full re-download when nothing changed.
  • A logged-in API response: Cache-Control: private, no-store - never persisted anywhere, since it is per-user and sensitive.

Conditional requests: revalidating cheaply

When a cached response needs revalidation (no-cache, or max-age expired), the client doesn't have to re-download the body - it asks "has this changed?" using a conditional request, built on a validator the server supplied earlier:

  • ETag - an opaque fingerprint of the response body (often a hash). The client echoes it back in If-None-Match.
  • Last-Modified - a timestamp. The client echoes it back in If-Modified-Since.

If the resource has not changed, the server replies 304 Not Modified with no body at all, just headers - the client reuses its cached copy. If it has changed, the server sends a normal 200 with the new body.

Worked example:

# First request
GET /style.css HTTP/1.1
Host: example.com
 
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "a1b2c3"
Content-Length: 8420
 
<css body>
 
# Later - browser revalidates before reuse
GET /style.css HTTP/1.1
Host: example.com
If-None-Match: "a1b2c3"
 
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"

The second exchange costs one small round trip instead of an 8 KB download. Multiply that across every asset on every page load and the savings compound fast.

Vary: caching correctly for different responses

Sometimes the same URL returns different content depending on a request header - a compressed body for clients that send Accept-Encoding: gzip, or a localized body based on Accept-Language. Vary tells caches which request headers were used to select the response, so they store separate copies keyed on those headers instead of serving the wrong variant to the wrong client:

Vary: Accept-Encoding, Accept-Language
Gotcha

Forgetting Vary: Accept-Encoding on a compressible resource is a classic bug: a shared cache stores whichever variant it saw first (say, gzip) and later serves that same compressed body to a client that never asked for gzip, breaking rendering.

Cache busting with fingerprinted URLs

immutable and year-long max-age only work safely because the filename changes whenever the content does - a build step hashes the file's contents into the URL (app.js -> app.3f9a1c.js). A new deploy produces a new filename, so there is no "is this stale?" question to answer: the URL itself guarantees freshness. The HTML that references the asset is served with no-cache so the new filename is always picked up immediately, while the asset behind it is cached forever.