So far we’ve talked about a cache, as if there’s one box sitting next to your database. In a real system there’s no single cache. A request passes through a whole chain of them, and each can answer on the way past.
This is Part 5 of the caching series. Part 4 was about keeping one cache honest. Now imagine six.
In one minute: A request can be answered by the browser, a CDN, a reverse proxy, your app’s memory, a shared cache like Redis, or the database’s own memory, in that order. The earlier it’s answered, the cheaper it is. HTTP headers decide what the outer layers may keep. Get them wrong and you either ship stale code to users or, worse, show one user another user’s data.
The Journey of One Request
Follow a request for a product page:
Each layer is faster, closer, and cheaper than the one below. Each also has its own rules for what it stores, how long, and who can invalidate it.
Facebook measured this for photos. In their study of the photo-serving stack, about 65.5% of requests were served by browser caches, 20.0% by edge caches, 4.6% by the origin cache, and only 9.9% reached backend storage (SOSP 2013). The layers peel traffic off like an onion. By the time a request reaches the bottom, most of its friends have already been answered upstairs.
That’s the happy picture. Now the control panel.
HTTP Caching: The Header Cheat Sheet
The outer layers (browser, CDN, proxies) don’t know anything about your business. They obey HTTP headers that your server sends with each response. The current spec is RFC 9111. Here are the ones you actually need.
| Header | What it says |
|---|---|
Cache-Control: max-age=3600 | Anyone may reuse this for 3600 seconds without asking |
Cache-Control: s-maxage=600 | Same, but only for shared caches like CDNs (overrides max-age there) |
Cache-Control: public | Shared caches may store it |
Cache-Control: private | Only the user’s own browser may store it, not a CDN |
Cache-Control: no-cache | You may store it, but revalidate with the server before every use |
Cache-Control: no-store | Don’t store it at all |
Cache-Control: immutable | This will never change, don’t even bother revalidating |
ETag and If-None-Match | A fingerprint; the client asks “still this version?” and gets a cheap 304 Not Modified |
Vary: Accept-Language | The response differs by this request header; cache separate copies |
The one everyone gets wrong: no-cache doesn’t mean “don’t cache.” It means “cache it, but check before using it.” The header that means “don’t cache” is no-store. I’ve seen those two swapped in production configs more than once.
Here’s all of that in motion. The same page is requested three times, by two different users. Pick a header and watch where each request gets answered, and which layers keep a copy. Compare no-cache with no-store.
- Request 1 (user A): went all the way to the database
- Request 2 (user A): answered by the browser, no network request at all
- Request 3 (user B): answered by the CDN
Revalidation is a clever middle ground
When a cached copy expires, the browser doesn’t necessarily re-download it. It sends a conditional request with the ETag. If the content hasn’t changed, the server replies 304 Not Modified with no body. You paid one round trip, not a whole download.
The Deploy Problem (and the Best Trick in Web Caching)
You ship a new version of your JavaScript. Users keep running the old one for hours, because their browsers cached app.js with a long max-age. You can’t tell their browsers to forget.
The fix is simple and used everywhere: put a hash of the content in the filename.
/assets/app.3f9a1c.js Cache-Control: public, max-age=31536000, immutable
/index.html Cache-Control: no-cache
The HTML is always revalidated, so users get the latest HTML right away. It references app.3f9a1c.js. When you deploy a new build, the hash changes, so the filename changes, so every cache sees a brand-new URL and fetches it. The old file can stay cached for a year, because nobody asks for it anymore.
Here’s the same deploy both ways. Watch what the returning user ends up running:
It’s the “versioned keys” idea from Part 4, applied to the web. No invalidation needed, because you never change an existing entry.
Where It Goes Wrong
1. Caching something personal
The CDN doesn’t know a page is personal unless you tell it. If a page contains someone’s account details and the response is cacheable by a shared cache, the next visitor to that URL may get it.
This isn’t theoretical. On Christmas Day 2015, Steam was hit by a DDoS that spiked traffic by around 2,000%. During the incident, a caching configuration change caused some users to be served pages generated for other users, including billing addresses, email addresses, and purchase history, for about 34,000 accounts. Valve said full credit card numbers and passwords weren’t exposed. They run Akamai as a CDN and Varnish for caching (Valve’s account, via TechSpot).
The rule: anything user-specific gets Cache-Control: private or no-store. And put it in a safe default, not on a per-endpoint checklist that someone will forget.
2. Web cache deception
Researcher Omer Gil showed in 2017 that you could trick a cache into storing private pages (Gil’s write-up). The idea: many servers will happily serve /account/settings for a URL like /account/settings/fake.css, ignoring the extra path. But the CDN sees .css and thinks “static file, I’ll cache that.” An attacker sends a victim a link to such a URL. The victim’s browser fetches their private page, the CDN stores it under that URL, and the attacker fetches the same URL to read it. He demonstrated it against PayPal.
Here’s the attack step by step, and what changes when the server marks the page private:
3. Web cache poisoning
The flip side: James Kettle’s 2018 research showed attackers could get a cache to store a malicious response and serve it to everyone (PortSwigger). It works when the response depends on some request input (a header like X-Forwarded-Host) that the cache doesn’t include in its key. The attacker sends a poisoned request once, and the cache serves the result to everyone after.
Both attacks come from the same root: the cache and your server disagree on what makes two requests “the same.” That’s worth remembering for any caching you build.
4. The shared layer is a shared failure
A CDN sits in front of a lot of sites. On June 8, 2021, Fastly went down for around three hours. A software bug that had shipped on May 12 was triggered by a valid customer configuration change, and 85% of their network returned errors until the offending config was found and disabled (Fastly’s summary). To be fair, this wasn’t a bug in caching logic. But it’s a good reminder of what happens when a layer is shared by many sites: when it fails, it fails for everyone, and your own caching strategy doesn’t help.
Which Layer for What?
| Layer | Good for | Watch out for |
|---|---|---|
| Browser | Static assets, per-user static-ish data | You can’t invalidate it. Use versioned URLs |
| CDN | Public pages, images, API responses that are the same for everyone | Personalization leaks, cache keys |
| In-app memory | Tiny, hot, rarely changing data (config, feature flags) | Every server has its own copy, so they disagree |
| Redis / Memcached | Shared computed results, sessions, rate-limit counters | It’s another network hop and another thing to keep alive |
| DB buffer pool | Automatic, nothing to do | Scans can flush it (Part 3) |
A rough rule I follow: cache as far from the origin as the data’s sharing allows. Data identical for everyone belongs at the CDN. Data per-user belongs near the app. Data that must be perfectly fresh may not belong in a cache at all.
Try It Yourself
Cache Lab scenario 5 simulates a request path with a toggle for every layer and a header inspector.
Challenge: make a deploy visible to all users immediately, without turning caching off. (Hint: the answer is in the filename.)
Summary
A request passes through a chain of caches, and each one can answer it. HTTP headers tell the outer layers what to keep and for how long. Know the difference between no-cache and no-store. Hash your asset filenames so deploys never need invalidation. Mark anything personal as private or no-store by default. And remember the outer layers are shared: when they misbehave, the blast radius is large.
Next up: When Caches Bite Back: Stampedes, Avalanches, and Penetration. What happens when a cache expires at exactly the wrong moment.
References
- Huang et al. An Analysis of Facebook Photo Caching, SOSP 2013
- Fastly. Summary of June 8 outage
- TechSpot. Steam Christmas day error that revealed users’ account details was due to caching issue
- Omer Gil. Web Cache Deception Attack, 2017
- James Kettle. Web Cache Entanglement: Novel Pathways to Poisoning, PortSwigger
- Fielding, Nottingham, Reschke. RFC 9111: HTTP Caching