#Where to cache
AddedA cache is a copy of an answer kept closer to whoever asks for it. In a design interview the question is almost never "should we cache?"; it's "at which layer, keyed by what, and how stale may the copy be?" A request for a product page passes through half a dozen places that could each answer it from a copy: the user's browser, a CDN edge, the API gateway, the application process, a shared cache such as Redis, and the database's own memory. Each layer you answer from skips everything behind it.
A mental model that sticks: think of a chain of stores between a factory and a customer. The customer's own cupboard (the browser cache), a local shop (the CDN edge near them), the factory's loading dock (the gateway), a shelf next to the production line (memory inside the app server), the factory's stockroom (Redis), and the production line itself (the database). Every store you buy from saves the trips behind it. But the closer a store is to the customer, the more copies exist and the harder a recall is: you can call the stockroom and pull a bad batch in a second, but you can't reach into every customer's cupboard. That one trade-off, savings against control, decides most layer choices.
Each layer also sees a different amount of context. The browser and the CDN only see the HTTP request (URL, a few headers, cookies), so they can only cache what is the same for everyone who sends that request. The application sees the authenticated user and the business objects, so it can cache per user, per tenant or per fragment. The further in you go, the finer the key can be and the smaller the saving.
What each layer is for, in the terms you'd use in a design review:
| Layer | Keyed by | Shared by | How you take a copy back | Good for |
|---|---|---|---|---|
| Browser HTTP cache | URL (plus Vary headers) | one user | you can't; change the URL instead | hashed static assets, the user's own data for a few seconds |
| CDN edge | URL plus the headers and cookies you choose | everyone near that edge | purge API, cache tags, TTL | images, scripts, public pages, public API responses |
| Gateway or reverse proxy | path and query | everyone in the region | short TTL, purge | expensive public responses the CDN can't hold |
| App memory (L1) | anything the code knows | one process | short TTL, or a broadcast to every server | tiny, very hot, rarely changing data: config, feature flags, the top 100 products |
| Shared cache (L2) | anything the code knows | every app server | DEL the key | most application caching: rows, query results, rendered fragments |
| Database buffer pool | pages | everyone | automatic | you don't manage it, but it is why "warm" databases are fast |
The model below puts numbers on the chain. It's a product page at a peak of 100,000 page-data requests a second, and each layer answers some fraction of whatever reaches it. The hit ratios and latencies are assumptions for the sketch, not measurements: 30% of views are repeats inside the browser's max-age, the CDN edge answers 80% of what reaches it, an in-process copy catches half of the rest, Redis 90% of what's left, and everything else is a database query. The latencies are typical rule-of-thumb figures (user to a nearby edge about 20 ms, edge to a distant origin about 60 ms more, a Redis call inside the data centre under a millisecond, an indexed query a few milliseconds); see Latency numbers to know for sourced figures.
// Assumptions for a popular product page at peak (all stated in the prose).
const peakRps = 100_000; // page-data requests per second from users
const layers = [ // [name, hit ratio of what reaches it, ms to answer a hit from the user's view]
["browser cache", 0.30, 0], // repeat views within max-age never leave the device
["CDN edge", 0.80, 20], // user -> nearby edge round trip
["app memory", 0.50, 80], // edge -> origin round trip, then a Map lookup
["Redis", 0.90, 81], // + one Redis round trip inside the data centre
["database", 1.00, 90], // + an indexed query that hits the buffer pool
];
let reaching = peakRps;
let latencySum = 0;
console.log("layer reaches it answered here share of all");
for (const [name, hit, ms] of layers) {
const answered = reaching * hit;
latencySum += answered * ms;
console.log(
`${name.padEnd(14)} ${String(Math.round(reaching)).padStart(9)}/s ${String(Math.round(answered)).padStart(12)}/s ` +
`${((answered / peakRps) * 100).toFixed(1).padStart(10)}%`,
);
reaching -= answered;
}
console.log(`mean latency seen by users: ${(latencySum / peakRps).toFixed(1)} ms`);
// What if one layer is switched off? Its traffic falls through to the next layer down.
for (const off of ["CDN edge", "Redis"]) {
let r = peakRps;
const load = {};
for (const [name, hit] of layers) {
load[name] = r;
if (name !== off) r -= r * hit;
}
console.log(`without ${off.padEnd(8)}: origin gets ${Math.round(load["app memory"])}/s, database gets ${Math.round(load.database)}/s`);
}layer reaches it answered here share of all
browser cache 100000/s 30000/s 30.0%
CDN edge 70000/s 56000/s 56.0%
app memory 14000/s 7000/s 7.0%
Redis 7000/s 6300/s 6.3%
database 700/s 700/s 0.7%
mean latency seen by users: 22.5 ms
without CDN edge: origin gets 70000/s, database gets 3500/s
without Redis : origin gets 14000/s, database gets 7000/s- Each layer only sees what the layer in front of it didn't answer.
reachingstarts at 100,000/s; the browser answers 30% (30,000/s) and passes 70,000/s on; the CDN answers 80% of those (56,000/s) and passes 14,000/s to the origin, and so on down to 700/s at the database. - The "share of all" column is the real story: the browser and the CDN together answer 86% of all traffic, and your servers never see it. Redis, the cache everyone draws first, answers only 6.3% of user requests, yet it's still what stands between 7,000/s and 700/s at the database.
- The mean latency is a weighted average: 30% of requests take 0 ms, 56% take 20 ms, and only the 14% that reach the origin pay 80–90 ms. That gives 22.5 ms, against roughly 90 ms if every request went all the way back.
- Switching off the CDN multiplies origin traffic by 5 (14,000/s becomes 70,000/s) and the database load by 5 too (700/s becomes 3,500/s), because every layer behind it receives more. Switching off Redis leaves the origin alone but multiplies the database load by 10, which is exactly
1 / (1 − 0.9). - So "add a cache" is really "decide which layer absorbs which traffic". The outer layers cut user latency and origin traffic; the inner layers cut database load. If the model's 80% CDN hit ratio became 95%, the origin would drop from 14,000/s to 3,500/s, which is often worth more than any server-side cache.
The browser can't be purged. Once a response is stored with Cache-Control: max-age=31536000, nothing you do on the server removes it from a user's disk before then. That's why static assets get a content hash in the file name (app.3f9a1c.js): a new version is a new URL, so the old copy simply stops being asked for. immutable (RFC 8246) tells the browser not even to revalidate such a file on reload. HTML, which must change when you deploy, gets a short max-age or no-cache (store it, but revalidate every time).
A shared cache must not see personal data. The CDN and gateway answer everyone who sends the same cache key. A response that depends on the cookie but is cached without the cookie in its key is served to the next visitor. Mark per-user responses Cache-Control: private (MDN: shared caches must not store it) and keep the public part of the page separate from the personal part.
The CDN only knows the request. Its cache key is the URL plus whatever headers or cookies you add to it, so every extra dimension multiplies the number of copies: varying on Accept-Language with 40 languages turns one cached page into 40, each with 1/40th of the traffic and so a far lower hit ratio. Normalise keys at the edge (drop utm_* tracking parameters, sort query parameters, collapse languages to the ones you actually serve) so equivalent requests share one copy.
An in-process cache multiplies by the number of servers. Fifty app servers mean fifty copies of every L1 entry, fifty misses to warm it, and fifty places to invalidate. Keep L1 for small, very hot, slow-changing things (feature flags, configuration, the hottest keys during a spike) with a TTL of seconds, and let the shared cache hold everything else.
The database cache isn't yours to tune key by key, but it explains a familiar effect: right after a restart the database is slow because its buffer pool is cold, which is the same warm-up problem as an empty Redis (see Distributed caches).
How long a CDN keeps an object is mostly up to your headers. On CloudFront, for example, s-maxage (or max-age) sets the edge TTL within the cache policy's minimum and maximum, and with no cache policy and no header the default TTL is 24 hours (AWS CloudFront docs, checked October 2026). CloudFront also honours stale-while-revalidate and stale-if-error (RFC 5861), which let the edge keep serving an expired copy while it refetches, or while the origin is down. Those two directives are the edge's built-in answer to the stampede and outage problems in Hot keys and cache stampedes.
A miss for something that doesn't exist goes to the database every time, because there's nothing to cache. A bot requesting random product IDs turns your cache into a pass-through. The fix is negative caching: store a small "not found" marker with a short TTL (say 30–60 seconds), so repeated lookups for the same missing ID stop at the cache. Keep the TTL short so a newly created item appears quickly, and delete the marker when the item is created. For huge ID spaces, a Bloom filter of existing IDs in front of the cache rejects most nonexistent IDs without storing a marker per ID.
Errors are different: don't cache a 500 as if it were the answer, or a one-second blip becomes a minute-long outage. Do the opposite: keep serving the last good copy while the origin is failing (stale-if-error at the edge, or a "serve stale on error" path in the app), so a downstream failure degrades freshness instead of availability.
A product page is identical for all visitors except a small "3 items in your cart" badge in the header. The team wants the CDN to cache the page, but the badge comes from the session cookie. What do you do?
Show answer
Cache the page without the personal part, and fetch the personal part separately.
- If the page is cached with the cookie in the cache key, every visitor has a different key, so every request misses: the CDN hit ratio drops to near zero and you've paid for an edge cache that does nothing.
- If it's cached without the cookie in the key, the first visitor's badge ("3 items") is shown to everyone. That's a correctness bug, and with names or addresses it's a privacy incident.
- The fix is to split the response: the HTML for the product is public (
Cache-Control: public, s-maxage=60, stale-while-revalidate=300, cookies stripped from the cache key), and the badge is filled in by a small uncached call such asGET /api/cart/summarywithCache-Control: private, no-store. - The edge now answers the expensive part for everyone, and the origin only serves the tiny per-user call. Some CDNs can also stitch the personal fragment in at the edge (edge-side includes or an edge function), which saves the extra round trip but keeps the same split.
- The general rule this shows: separate what varies per user from what doesn't, and cache each at the outermost layer that can hold it safely.