Skip to content
JZLeetCode
Go back

System Design - How HTTP Caching Works

Table of contents

Open Table of contents

The page that was fast for the wrong reason

Imagine that an online store has just changed the price of a product. The origin server returns the new HTML immediately, yet some visitors still see the old price. Others get the new price, and a few are served a page without touching the origin at all.

That is not necessarily a bug in the browser. HTTP caching is a distributed agreement between an origin, browsers, and shared caches such as reverse proxies or CDNs. The agreement answers two questions for each stored response:

  1. May I use this response without asking the origin? This is freshness.
  2. If I must ask, can I cheaply prove that my stored response is still current? This is validation.

Caching is powerful because it trades a repeat trip to the origin for a local response. It is dangerous when a response belongs to one user, changes more often than its policy says, or varies by request data that a cache cannot distinguish.

One request can meet several caches

The word “cache” does not name one machine. A request might encounter a browser cache, a service-worker cache, a company proxy, and a CDN before it reaches the application. Each cache uses the response headers and its own stored copy.

browser
  |
  | GET /products/42
  v
+----------------+       fresh hit       +----------------+
| browser cache  |---------------------->| render page    |
+----------------+                       +----------------+
  | stale or absent
  v
+----------------+       fresh hit       +----------------+
| CDN / proxy    |---------------------->| return bytes   |
+----------------+                       +----------------+
  | stale or absent
  v
+----------------+                       +----------------+
| origin server  |---------------------->| database/app   |
+----------------+                       +----------------+

A cache key normally starts with the request method and target URL. A response can add request-header dimensions through Vary. For example, Vary: Accept-Encoding tells a cache that gzip and Brotli representations cannot be substituted for one another. Vary: Cookie is usually a warning sign for a shared cache: it creates many variants and is easy to get wrong.

Freshness: a promise with an expiration time

An origin can give a response a freshness lifetime with Cache-Control: max-age=<seconds>. While a cached response is younger than that lifetime, a cache may reuse it without contacting the origin.

Cache-Control: public, max-age=600

The response is fresh for ten minutes. public permits a shared cache to store it; private says a shared cache must not store it, though a browser may. A cache also computes the response’s current age from its Date header and the time it has been stored, so it does not accidentally give an already-old response a second full lifetime.

The useful directives are deliberately small in number:

DirectiveMeaning
max-age=600A browser or shared cache may reuse the response for 600 seconds.
s-maxage=600Overrides max-age for shared caches only.
no-cacheStorage is allowed, but reuse needs successful validation first.
no-storeDo not store the response. Use for sensitive data that should not remain in cache storage.
privateDo not store in a shared cache.
immutableWhile fresh, the representation will not change; especially useful for versioned static assets.

no-cache is often misunderstood. It does not mean “do not cache.” It means “do not reuse without asking.” A validator can make that asking very cheap.

Validation: asking without downloading again

Once a response is stale, a cache does not have to download its body again. The origin can provide a validator such as an ETag or Last-Modified timestamp. On a later request, the cache presents that validator conditionally.

cache                              origin
  |                                   |
  | GET /logo.svg                    |
  | If-None-Match: "a91f"             |
  |----------------------------------->|
  |                                   | compare current representation
  |       304 Not Modified            |
  |<-----------------------------------|
  | reuse stored body; update metadata |

304 Not Modified has no new body. It says the cached body remains valid, which saves transfer bytes and often application work. If the content changed, the origin replies 200 OK with the new representation and a new validator.

ETag is an opaque version label. It need not be a literal file hash; the origin only has to change it when the selected representation changes. Last-Modified / If-Modified-Since is easier to generate, but timestamp resolution and clock behavior can make it less precise. If both are available, clients typically use the ETag condition.

The two policies that avoid most surprises

Static assets and personalized documents deserve different policies.

Versioned assets: cache for a long time

Build tools commonly put content fingerprints in asset names:

/assets/app.7d4e1c.js
/assets/site.91b3aa.css

When bytes change, the URL changes. That makes a long policy safe:

Cache-Control: public, max-age=31536000, immutable

The HTML document is the directory that points at these assets, so it gets a shorter policy or validation policy. A deploy changes the HTML to name the new asset; clients can keep old assets for pages that still refer to them without seeing a mix of old and new bytes.

HTML and API responses: choose correctness first

For a public product page, a short shared-cache lifetime can be useful:

Cache-Control: public, max-age=60, s-maxage=300

Browsers recheck after one minute, while a CDN can reuse the response for five minutes. The precise values are product decisions, not universal defaults: they define how stale a result may be.

For an authenticated account page, avoid a shared copy:

Cache-Control: private, no-cache

The browser may store the page, but must validate before reuse. For highly sensitive responses, such as a one-time recovery-code page, use no-store and consider the rest of the delivery path as well.

A small cache decision tree

received a request
        |
        v
does a matching stored response exist? -- no --> fetch from origin
        |
       yes
        |
        v
is it fresh under its Cache-Control policy? -- yes --> serve it
        |
       no
        |
        v
does it have a validator? -- yes --> conditional request --> 304: serve stored body
        |                                        |
       no                                        `--> 200: replace body
        |
        `--> fetch a full new response

Two details make this tree more subtle in production. First, a cache must honor the request and response directives that restrict reuse; it cannot decide that a convenient stale result is “probably fine.” Second, invalidation is not magic. Purging a CDN key can help after an emergency change, but versioned URLs and deliberate short policies are more reliable routine tools.

Common failure modes

The durable mental model is simple: every response carries a reuse contract. Cache-Control sets the allowed age, validators make rechecking cheap, and the cache key determines which request is allowed to reuse which representation. Once those three pieces are explicit, cache behavior stops feeling mysterious.

References

  1. IETF, RFC 9111: HTTP Caching.
  2. IETF, RFC 9110: HTTP Semantics, especially conditional requests and validators.
  3. MDN Web Docs, HTTP caching.
Share this post on:

Next Post
System Design - How TiDB Statement Summary Works in Source Code