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:
| Cache | Lives | Shared across users? |
|---|---|---|
| Browser cache | On the client's disk/memory | No - one user |
| CDN edge | Points of presence near users | Yes - all users at that PoP |
| Reverse proxy | In 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):
| Directive | Meaning |
|---|---|
| max-age=N | Fresh for N seconds from when the response was generated |
| no-cache | May be cached, but must revalidate with the origin before every reuse |
| no-store | Must not be cached anywhere - not even briefly, not even the body |
| private | Only the end-user's browser may cache it, not a shared cache/CDN |
| public | Any cache, including shared ones, may store it |
| immutable | Never changes for the life of this URL - skip revalidation entirely, even on refresh |
| stale-while-revalidate=N | Serve the stale copy immediately, then refetch in the background for N seconds |
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 inIf-None-Match.Last-Modified- a timestamp. The client echoes it back inIf-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:
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:
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.