Where caches live
Think of how you get milk. There’s a carton in your fridge (fastest), a corner shop down the street, a supermarket across town, and the dairy farm (the original source, slow to reach). Most days the fridge is enough; you only go further when it’s empty.
A request for data works the same way. Each layer between the user and the database can keep a copy, and the closer the copy, the faster the answer.
Each layer trades freshness and control for latency and offload. Client and CDN caches remove requests entirely (no server cost at all) but are the hardest to invalidate, so they’re controlled by HTTP semantics (Cache-Control, ETag, Vary). Server-side caches (in-process, then distributed) reduce database load under your direct control. The design question is which data can tolerate which staleness at which layer, and which data must never be shared.
Layers of caching
Tap a box to see what it does.
App / browser
Fastest: no network at all. Controlled by Cache-Control headers and the app's own memory or disk caches (images, last feed). Can't be invalidated from the server, only expired.
CDN
Servers near users that keep copies of public responses: images, scripts, public API responses. One copy serves thousands of users. Must never store personalized responses.
App server
A small cache in each server's memory: microseconds, but each server has its own copy, and copies can disagree for a while.
Shared cache
One cache all servers share: about a millisecond away, one copy to update or delete. Typical home for cache-aside data.
Database
The source of truth. It also caches hot pages in memory, which is why repeated queries get faster, but every query still costs CPU and a connection.
HTTP caching is how the server tells the app and the CDN what they may keep:
Cache-Control: max-age=300means “you may reuse this response for 300 seconds without asking me”.Cache-Control: privatemeans “only the user’s own app or browser may keep it, never a shared cache like a CDN”.publicallows shared caches.Cache-Control: no-storemeans “don’t keep this anywhere” (for sensitive data).- An
ETagis a fingerprint of the response. Next time, the client sendsIf-None-Matchwith it, and if nothing changed, the server answers304 Not Modifiedwith no body, saving bandwidth.
Tap the line with the bug.
@GetMapping("/me")
fun me(@AuthenticationPrincipal user: User): ResponseEntity<Profile> =
ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(profiles.load(user.id))@GetMapping("/me")
ResponseEntity<Profile> me(@AuthenticationPrincipal User user) {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(profiles.load(user.id()));
}app.get("/me", requireAuth, async (req, res) => {
res.set("Cache-Control", "public, max-age=300");
res.json(await profiles.load(req.user.id));
});Hint: The API sits behind a CDN. Ana opens her profile, then Ben opens his, a minute later, through the same edge server. What does the CDN see in both requests?
/me returns a different response for every user, but it’s marked public, so the CDN stores Ana’s profile under the URL /me and serves it to everyone who asks for /me in the next five minutes, Ben included. That’s a data leak caused by a header.
Personalized responses must never be stored by shared caches:
.cacheControl(CacheControl.noStore()) // or maxAge(...).cachePrivate() for the user's own device.cacheControl(CacheControl.noStore()) // or maxAge(...).cachePrivate() for the user's own deviceres.set("Cache-Control", "private, no-store");Configure the CDN to bypass the cache for authenticated requests by default, and to cache only paths you explicitly mark as public (/products/*, /images/*). When a public response varies by a header, like language, say so with Vary: Accept-Language, or each language overwrites the other.
Predict the output
The product list is served with Cache-Control: public, max-age=300. At 10:00:00 a shop owner changes a price. At 10:02:00 a customer who loaded the list at 09:59:00 refreshes. Which price can they see?
The customer's copy was stored at 09:59 and may be reused for 300 seconds, until 10:04, without asking the server. A CDN copy may be just as old. The server can't recall copies it already gave out; it can only choose a shorter max-age, use ETag revalidation (max-age=0, must-revalidate asks the server every time but saves bandwidth), or put a version in the URL so new data has a new address. For prices, a short max-age or revalidation is the usual choice; for images and app bundles, long max-age with versioned URLs.
HTTP caching that scales
- Immutable assets: content-hashed URLs (
app.3f9a2c.js,/images/{hash}.jpg) withCache-Control: public, max-age=31536000, immutable. “Invalidation” is a new URL. - Public API data: short
max-ageplusstale-while-revalidatelets clients and CDNs serve slightly stale data instantly while refreshing in the background;s-maxagesets a different TTL for shared caches than for browsers. - Revalidation: strong
ETags (orLast-Modified) turn repeat loads into304s; on mobile this saves battery and data for large JSON payloads. - CDN purge exists but is eventually consistent across edge locations; design so correctness never depends on a purge arriving.
- Mobile apps cache too:
URLCacheon iOS and OkHttp’sCacheon Android honor these headers if you configure a disk cache, which is a cheap way to make cold starts and offline reads faster.
- Layers: client (HTTP cache, app memory/disk) → CDN → in-process → shared (Redis) → database buffer pool. Earlier layers save more and are harder to invalidate.
Cache-Control:private/public,max-age,s-maxage,no-store,stale-while-revalidate,immutable;ETag+If-None-Match→304;Varyfor header-dependent responses.- Personalized responses are
private/no-store; CDNs bypass authenticated traffic by default. Version URLs instead of relying on purges.
Check this part
What does Cache-Control: private mean?
Why can a user still see an old price after it changed?
How should static assets be cached and "invalidated"?