# HTTP Caching Without the Magic

> Learn how browsers and CDNs choose, reuse, and revalidate responses with Cache-Control, cache keys, and ETags.

- Author: Aman Kumar Gupta (akgbytes)
- Published: 2026-06-11
- Tags: HTTP, Caching, Web Performance
- Series: Modern Web Systems from First Principles, part 4
- Canonical source: https://akgbytes.in/blogs/http-caching-without-the-magic

A fast response is often the one your application never has to generate.

HTTP caches can answer requests from stored responses. That saves network time, reduces origin traffic, and gives your server fewer requests to process.

The behavior is not magic. A cache makes three decisions: **which stored response matches, whether it is fresh, and whether it may be reused**.

This article builds those decisions into one practical mental model.

## The browser, shared cache, and origin

A request can pass through more than one cache before reaching your application.

A browser cache is **private**. It stores responses for one user agent. A CDN or reverse proxy is a **shared cache** because it can reuse one response for many users.

The origin is the server responsible for the resource. It creates a response when no usable stored response exists.

```text
Browser → shared cache → origin
```

Each cache evaluates the response under HTTP rules and its own configuration. A browser hit does not contact the CDN. A CDN hit does not contact the origin.

![How a browser, shared cache, and origin handle fresh, missing, and stale HTTP responses.](https://akgbytes.in/articles/diagrams/http-cache-working.svg)

## A cache first needs the right response

Before checking age, a cache must find a stored response that matches the request.

The request method and target URI form the starting point. For ordinary `GET` requests, different paths or query strings normally identify different resources.

```text
GET /products?page=1
GET /products?page=2
```

Those requests must not accidentally share the same page of results.

The origin can add request headers to the selection rule with `Vary`. This response says that compressed and uncompressed representations are different:

```http
Vary: Accept-Encoding
```

A cache may reuse that stored response only when the new request's nominated header values match the original request.

This lookup identity is commonly called the **cache key**. CDNs may support custom keys, but removing a value is safe only when that value cannot change the response.

Never ignore a cookie, authorization value, locale, or device signal while still varying the response by it. Doing so can serve the wrong representation—or another user's data.

## Fresh and stale are about age

A stored response has an age. Its freshness lifetime says how long it may normally be reused without checking the origin.

While the response is within that lifetime, it is **fresh**. After the lifetime passes, it is **stale**.

Stale does not mean deleted or incorrect. It means the cache cannot normally reuse the response without validation or explicit permission to serve stale content.

The `Age` response header reports how long a response has been held in caches along the path, in seconds. It is useful when debugging shared-cache behavior.

## `max-age` and `s-maxage`

`max-age` gives a response a freshness lifetime in seconds.

```http
Cache-Control: max-age=300
```

For five minutes, an eligible cache may reuse the response without contacting the origin.

`s-maxage` sets the lifetime for shared caches and overrides `max-age` there. Private caches still use `max-age`.

```http
Cache-Control: max-age=60, s-maxage=3600
```

The browser can reuse this response for one minute. A compliant shared cache can keep it fresh for one hour.

These directives define freshness, not invalidation. If the resource changes early, you need a purge mechanism, a versioned URL, or a shorter lifetime.

## `public` and `private`

`private` tells shared caches not to store the response. A private browser cache may still store it if the other directives allow that.

```http
Cache-Control: private, max-age=60
```

This can suit a user-specific dashboard that the user's own browser may reuse briefly.

`public` explicitly permits caching even when the response would otherwise be ineligible, such as a response to a request carrying `Authorization`.

That makes `public` a security decision, not decorative documentation. Use it only when the response is genuinely identical and safe for different users.

Public responses do not always need the word `public`. Many ordinary `GET` responses with explicit freshness are already cacheable under HTTP rules.

For personalized or permission-dependent responses, prefer `private` or `no-store`. Do not rely on a CDN noticing that the body looks personal.

## `no-cache` is not `no-store`

The names are easy to misread.

`no-cache` allows storage, but the stored response must be successfully validated before reuse.

```http
Cache-Control: no-cache
```

This can save bandwidth when validation proves the content unchanged.

`no-store` tells private and shared caches not to store the request or response.

```http
Cache-Control: no-store
```

Use it when retaining the response is unacceptable, such as for highly sensitive data. It is stronger than merely forcing a check before reuse.

Neither directive erases copies already stored before the new policy was deployed. Purging old shared entries and changing URLs may still be necessary.

## Validators: ETags and conditional requests

Validation asks the origin whether a stored representation is still current.

An `ETag` is an opaque identifier chosen by the server for a representation. The client should return it exactly; it should not infer meaning from its contents.

```http
HTTP/1.1 200 OK
ETag: "product-list-v7"
Cache-Control: no-cache
```

On the next request, the cache sends a condition:

```http
If-None-Match: "product-list-v7"
```

If the selected representation still has that tag, the server can return `304 Not Modified` without sending the body again.

```http
HTTP/1.1 304 Not Modified
ETag: "product-list-v7"
```

The cache then updates its stored metadata and reuses the body it already has. If the tag changed, the origin sends a new response and body.

`Last-Modified` and `If-Modified-Since` provide time-based validation. ETags can distinguish changes more precisely when timestamps are too coarse or unavailable.

Validation still makes a network trip. It saves response bytes and generation work, but it is slower than serving a fresh response locally.

## `stale-while-revalidate`

Sometimes a slightly old response is preferable to making a user wait.

`stale-while-revalidate` grants a window in which a cache may serve a stale response while it revalidates in the background.

```http
Cache-Control: max-age=60, stale-while-revalidate=300
```

For 60 seconds, the response is fresh. During the next 300 seconds, a supporting cache may return the stale copy immediately and start revalidation.

Once revalidation completes, later requests receive the updated response. Concurrent requests can avoid all waiting on the same origin fetch.

Support and exact request-collapsing behavior vary by cache. Treat stale delivery as an allowed behavior, not a timing guarantee.

Framework features may use their own cache stores and invalidation systems. For example, Next.js revalidation is not merely this response directive with a different name.

## The complete lifecycle

Put the pieces together for `GET /catalog`:

1. The cache builds or evaluates the cache key.
2. No entry means a miss, so the request continues toward the origin.
3. The origin returns a response with caching metadata.
4. An eligible cache stores the response.
5. Matching requests reuse it while it is fresh.
6. Once stale, the cache validates it or serves it only when stale use is allowed.
7. A `304` keeps the stored body; a new `200` replaces it.

![The lifecycle of an HTTP cache entry from missing to fresh, stale, and validation.](https://akgbytes.in/articles/diagrams/complete-request-lifecycle.svg)

## Avoiding personalized-data leaks

Caching bugs are dangerous because a perfectly fast response can still belong to the wrong person.

Before allowing shared caching, ask whether authentication, cookies, headers, location, experiments, or permissions can change any byte or meaningful header in the response.

If they can, either keep the response out of shared caches or include the relevant input in the cache selection rules. The safer default for user-specific data is `private` or `no-store`.

Do not put secrets in URLs. URLs appear in cache keys, logs, browser history, analytics, and referrer data.

Test as two users. A cache configuration is incomplete until you prove that one user's response cannot satisfy the other's request.

## Key takeaways

- A cache first selects a matching response, then checks whether it may be reused.
- The URI, method, and `Vary` headers shape response selection.
- `max-age` controls freshness; `s-maxage` overrides it for shared caches.
- `private` blocks shared storage, while `public` can explicitly allow it.
- `no-cache` means validate before reuse; `no-store` means do not store.
- ETags make validation conditional and can avoid sending an unchanged body.
- `stale-while-revalidate` trades bounded staleness for lower latency.
- Personalized data requires conservative cache rules and isolation tests.

## What comes next

HTTP caching absorbs repeated reads before they reach your application. Requests that do reach a serverless function can still multiply rapidly.

The next article follows that fan-out to a resource that does not scale in the same way: database connections.

## Further reading

- [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html)
- [RFC 5861: HTTP stale response controls](https://www.rfc-editor.org/rfc/rfc5861.html)