What you'll learn: By the end of this lesson you will be able to name the four cache layers between a user and the data, say for each one what it stores, who owns it, whether it is private or shared, and whether you can reach in and purge it, calculate how much staleness a stack of layers can add up to, read the HTTP headers that steer the browser and CDN layers, explain what the database's own caches do and do not do, and choose the layer where a given piece of data belongs.
A clerk at a head office is asked for a customer's address. She checks the sticky note on her monitor first. If it is not there she asks the branch office down the road, which keeps a binder of the addresses its own customers ask for. If the binder is silent she opens the head-office filing cabinet, and only after that does she send someone to the archive in the basement. Every step is slower than the one before, and every step can hold a copy that is out of date. The sticky note is yours alone, the binder serves everyone at that branch, the cabinet serves the whole building, and the archive is the original. Nobody can walk into a customer's home and rewrite their sticky note.
That is a stack of caches. Caching Basics explains why one cache works and Advanced Caching covers write policies, invalidation and stampedes. This lesson is about the layers themselves: what is different about a cache in a browser, at a CDN edge, next to your code or inside the database, and what happens when they are stacked. CDN and Object Storage goes deeper on the CDN layer.
Verified against the RFC 9111 text, PostgreSQL 18, MySQL 8.0 and MySQL 8.4 documentation, the Amazon CloudFront developer guide and the Redis documentation, all read on 2026-11-08. Defaults differ between products and versions, so check the page for the version you run.
The core idea
A cache layer is defined less by what it stores than by who controls it. Every layer holds a copy and trades freshness for speed, so the useful questions are about control: Who decides when the copy is dropped? Is it shared between users? Can the owner of the data reach in and remove it? How far is it from the user, and how much of the work does a hit skip?
The closer a layer sits to the user, the more latency a hit saves and the less control you have over it. The browser cache is the fastest and you cannot touch it. The application cache is slower and entirely yours. The rest of the lesson follows that tension.
Three illustrations run through the lesson, each labelled:
- Minimal example: one HTTP response passing through a browser, a CDN, a Redis cache and a database, with a cold path and a warm path.
- Realistic example: ShopStream's product page when a price changes, in "In practice".
- Failure example: a personalised response stored in a shared cache and served to a stranger.
The four layers at a glance
Figure 1. The layers, grouped by how much control you have. How to read this: a request travels left to right until some layer has a fresh copy; every arrow labelled "miss" is a layer that did not have one, and the application looks in Redis before it asks the database. The zone labels are the lesson in one line: control grows as you move away from the user.
| Layer | Holds | Private or shared | Who sets its lifetime | Can you purge it? |
|---|---|---|---|---|
| Client (browser, mobile app) | HTTP responses, files, app state | Private to one user's device | Your response headers, and the client's own rules | No. You can only wait for expiry or change the URL |
| CDN edge | Responses for many users | Shared by everyone routed to that edge | Your headers plus the CDN's own TTL settings | Usually yes, through the provider's purge or invalidation tools |
| Application | Query results, fragments, sessions, computed values | In-process: one server. Redis or Memcached: all servers | Your code | Yes, immediately |
| Database | Table and index pages in memory | Shared by every query | The database engine | Not individually. You size it; the engine evicts |
Minimal example. One request for GET /api/products/123. Cold, it misses every layer, and the response fills each cache on its way back up.
Figure 2. A cold request fills every layer on the way back. How to read this: time runs downward. The first request does all the work. Afterwards a repeat from the same browser costs nothing, and a request from another user stops at the CDN. The database sees one query for however many requests share the copy.
Layer 1: the client
The client cache is the browser's HTTP cache (and the equivalent in a mobile app's networking library, plus any state the app keeps in memory). It is private: one cache per browser profile, never shared with another person. RFC 9111, the HTTP caching specification, defines a private cache as one dedicated to a single user and a shared cache as one that serves more than one user.
What it stores and for how long is steered by the response headers you send:
| Header | What it tells the cache |
|---|---|
Cache-Control: max-age=N |
The response is stale once its age passes N seconds |
Cache-Control: s-maxage=N |
The same, for shared caches only; it overrides max-age and Expires there |
Cache-Control: private |
A shared cache must not store this response; a private cache may |
Cache-Control: no-cache |
May be stored, but must be validated with the origin before every reuse |
Cache-Control: no-store |
Do not keep any part of the request or response (the RFC notes this is not a reliable privacy mechanism) |
Vary: Accept-Language |
The stored copy is reusable only for requests whose named headers match |
The freshness lifetime is chosen by a fixed order, the first rule that matches wins: s-maxage (shared caches only), then max-age, then Expires, and finally a heuristic if none is present (RFC 9111, section 4.2.1). The specification suggests that a heuristic uses a fraction of the time since Last-Modified, typically about 10 percent (section 4.2.2), so a response with no explicit lifetime can still be reused for a while. Send an explicit header for everything you care about.
Two properties define this layer.
- It is the cheapest hit there is. A fresh hit needs no network round trip, so no server, CDN or database does any work. For static files that never change under one URL, this is the layer to use fully: a content-hashed filename with a very long
max-age. - You cannot purge it. Once the browser has a fresh copy, you have no channel to the user's device. If the data changes, the user keeps the old copy until it expires. So the lifetime you hand to a browser must be one you can live with being wrong, and changing the URL (versioning the key) is the only real invalidation here. The front end's side of this layer belongs to the Frontend System Design track.
Server-assisted client caching. A back-end service can also hold a local in-memory cache of data from a remote store, and the same "cannot purge" problem appears in miniature. Redis 6 added direct support, called tracking. In the default mode the server remembers which keys each client read and sends an invalidation message when one is modified, or evicted because it expired or because of the memory policy. In broadcasting mode the server remembers nothing; clients subscribe to key prefixes, so the server spends no memory but sends more messages. The documentation lists the hazards a client must handle: flush the local cache when the invalidation connection is lost, put a maximum TTL on every local entry, and guard against an invalidation that arrives before the reply to the read it concerns. The pattern suits small, hot, read-mostly data, and the server's invalidation table can itself evict entries.
Layer 2: the CDN
A CDN edge cache is a shared cache run by someone else, in many locations, in front of your origin. Every user routed to the same edge shares its contents, so one miss pays for many later hits. It is the first layer where you can both save a huge amount of work and still change your mind: unlike the browser, the edge is something you can purge through the provider's tools. (CDN and Object Storage covers cache keys, purging and cost.)
What the CDN does with your headers is provider-specific, so read the provider's page. Amazon CloudFront's developer guide is a good example of the shape:
- With no cache policy, the default TTL is 24 hours; minimum, maximum and default TTL settings in the cache behaviour interact with the origin's headers.
- With
Cache-Control: max-ageand a minimum TTL of 0, CloudFront caches for the lesser ofmax-ageand the maximum TTL. Ifs-maxageis also present, the edge usess-maxage(again capped by the maximum TTL) and browsers usemax-age, which is how you give the edge a longer life than browsers. - It supports
stale-while-revalidateandstale-if-error, serving a stale object while it is refreshed or while the origin is failing. (RFC 9111 itself does not define them.) - When an object expires, CloudFront asks the origin whether it is still current;
304 Not Modifiedkeeps the copy. - An edge may evict a rarely requested object before it expires, and a viewer's
Cache-ControlorPragmarequest headers do not force it back to the origin.
Two lessons follow for any CDN. First, expiry is not residency: a cached object can disappear before its TTL, so the CDN never guarantees a hit, only the right to serve one. Second, a CDN is a shared cache, so anything user-specific must be kept out of it. Cache-Control: private, or no-store, and a correct Vary are what keep a personalised page from being served to the next visitor. The failure example below is exactly this mistake.
What belongs here: static assets, public images and pages, and any API response identical for everyone that tolerates short staleness. What does not: carts, sessions and anything keyed by who is asking.
Layer 3: the application
The application cache is the layer you build. It has two forms with different trade-offs.
| Form | Where it lives | Latency | Shared across servers? | Typical content |
|---|---|---|---|---|
| In-process | Memory of each app server | Memory access, no network | No: every server has its own copy | Configuration, small hot lookups |
| Distributed (Redis, Memcached) | A separate service all servers share | One network round trip | Yes | Query results, rendered fragments, sessions, rate-limit counters |
The in-process form is faster and simpler but fragments: with twelve servers there are twelve copies, they can disagree, and each warms up separately. The distributed form costs a network hop and is one more thing to run, but one fill serves every server and one delete removes the entry for all of them. Many systems use both: a tiny in-process cache with a seconds-long TTL in front of Redis, so the hottest keys do not cost a network call. The patterns for filling and invalidating this layer (cache-aside, write-through, stampede protection) are in Advanced Caching; the capacity side of it (what happens when it fills up) is the subject of Eviction Policies: LRU, LFU and TTL.
This is the only layer where an entry's lifetime can be tied to a business event: when a price changes, application code can delete the Redis key in the code path that commits the change. It is also the layer you are responsible for keeping consistent with the source of truth. A cache that is the only holder of a fact is a database with no durability.
Layer 4: the database
Databases cache too, and you mostly do not control it. A relational database keeps recently used table and index pages in memory so repeated reads skip the disk. In PostgreSQL that memory is shared_buffers; the PostgreSQL 18 manual gives a default of typically 128 MB and suggests 25 percent of system memory as a reasonable starting value on a dedicated server with 1 GB or more of RAM. It adds that because PostgreSQL also relies on the operating system's cache, a setting above 40 percent of RAM is unlikely to help. So a PostgreSQL read can be served from shared_buffers, from the operating system's file cache, or from disk, and the database does not give you a key to purge.
Two related facts keep this layer in perspective:
- A related planner setting,
effective_cache_size, does not allocate anything. The manual says it only tells the planner how big the cache available to a query is likely to be (default 4 GB), which influences whether it favours index scans. It is an estimate, not a cache. - MySQL's InnoDB manages its buffer pool as an LRU list with a midpoint: a newly read page enters the middle of the list, at the head of the "old" sublist (3/8 of the pool by default), and an access to an old page makes it "young", moving it to the head of the young sublist. The manual warns that by default pages read by queries are moved to the young sublist at once, so a table scan, such as a
mysqldumpor aSELECTwith noWHEREclause, can still evict frequently used pages, and it describes tuning to limit that. This is the scan problem that the eviction lesson takes apart.
MySQL also shows that a database-side result cache is hard to keep correct: the query cache, which stored the text of a SELECT with its result, was removed in MySQL 8.0, along with its statements and variables. PostgreSQL has no equivalent result cache in its core. If you want to cache query results, you do it in the layer above (Layer 3) where you control invalidation.
Everything upstream exists so that the database sees only the requests nobody else could answer.
How the layers multiply
The layers multiply. If the CDN answers 60 percent of requests and the application cache answers 90 percent of the rest, the database sees 40 percent of 10 percent: 4 percent of the original traffic. Those percentages are illustrative. A subtlety is worth knowing: the second cache sees only what the first one let through. A CDN absorbs the hottest items, so the application cache behind it faces a flatter, colder stream and its own hit ratio is usually lower than it would be alone. Measure each layer's hit ratio separately, and judge it against the traffic it actually receives.
Staleness across layers
Every layer adds a way for the user to see old data. Two rules help you reason about the total.
Rule 1: purge what you can, and give short lifetimes to what you cannot. Deleting a Redis key fixes Layer 3 at once. A CDN purge fixes Layer 2 in whatever time the provider takes. Layer 1 cannot be fixed, so the browser's lifetime sets a floor under how stale a user can be.
Rule 2: lifetimes add up unless the layers share the age. If each cache starts its own clock when it receives a response, the worst case is the sum of the lifetimes. HTTP caches are designed to avoid that: the Age header conveys the sender's estimate of the time since the response was generated or validated at the origin (RFC 9111, section 5.1), and the receiving cache adds it to the age it calculates for itself (current_age, section 4.2.3). A browser that receives a response with Age: 58 and max-age=30 treats it as already stale. So where every HTTP cache in the path passes Age along, staleness is bounded by the longest TTL, not their sum. Caches that sit outside HTTP, such as a Redis entry sitting in front of the origin, do not propagate an age to downstream caches, so their lifetimes do add.
Worked example (illustrative numbers). A response carries Cache-Control: public, max-age=30, s-maxage=60. The origin's Redis entry has a 600 second TTL and is deleted on every write.
| Situation | Worst-case staleness after a database commit |
|---|---|
| Redis delete succeeds | 0 s from Redis. The CDN may hold a copy fetched just before the commit: up to 60 s old. A browser that got it from the CDN at Age 59 is stale at once, so if Age is passed the total stays about 60 s |
Age is not passed or not honoured |
Up to 60 s (CDN) plus 30 s (browser) = 90 s |
| The Redis delete is lost (a network error, a bug) | Redis can keep the old value for its whole TTL: 600 s, then 60 s CDN, then 30 s browser. Up to 600 + 60 + 30 = 690 s |
The last row is why every entry keeps a TTL even with explicit invalidation: it turns "unbounded" into "690 seconds". It is also why the TTL of a layer you do not control (the browser) should be the shortest of the three.
Figure 3. The write reaches the layers in order of how much you control them. How to read this: the delete on Redis is instant, the CDN purge takes effect when the provider processes it, and the browser changes only when its own timer ends. Whatever the browser holds is the limit on how fast a user can see the change.
Choosing a layer
Figure 4. A per-resource decision. How to read this: walk each response through the three questions. Per-user data goes down the first branch and never reaches a shared layer. Files with versioned names go left to long lifetimes. Changing data needs a staleness budget you can state in seconds.
Rules of thumb that fall out of the layers: push static, shared, versioned bytes outward to the browser and CDN; cache changing, shared data behind a short CDN lifetime and a deletable app cache; keep per-user data in the application tier, keyed by user, or in the browser with private; and never cache what must be exact at the moment of use (stock at payment time, an authorisation that must reflect revocation instantly): read the source of truth.
Failure example: a personalised response in a shared cache
A shared cache assumes that a response with the same URL is the same response for everyone. When a personalised response carries no private or no-store, and nothing in Vary separates the users, a CDN can store the first visitor's copy and serve it to the second.
Figure 5. The cache key was the URL, and the URL does not say who is asking. How to read this: nothing is broken in the CDN. It did what the headers allowed. The bug is the missing Cache-Control: private (or no-store) on a response that depends on the caller.
What breaks: privacy, and trust. Detection: a test that requests a personalised URL as two different users and fails if the bodies match, plus a header check on every authenticated route. Prevention: send Cache-Control: private (a shared cache must not store it, per RFC 9111) or no-store for anything that depends on the caller, and use Vary only when sharing is genuinely wanted. no-store is not a security control by itself: the RFC says it is not a reliable privacy mechanism.
The other side of this topic
The front end sees the same layers from the other end: how the browser stores, revalidates and evicts responses, how a service worker and an in-memory data cache add layers of their own, and how a deployment that changes file names interacts with all of them. The Frontend System Design track covers this in its lessons on HTTP caching and on caching across the stack, which complement this one. The back-end rule that makes the front end's job possible is the one in this lesson: send explicit, correct cache headers, and know which layers you can purge.
Trade-offs
| Layer | Gain | Cost | Wrong choice when |
|---|---|---|---|
| Client | Zero server work, fastest hit | Cannot be purged, staleness is on the user's device | The data changes and must show immediately |
| CDN | Offloads origin, short distance to users | Shared (leak risk), purge delay, provider cost | The response is personalised or the URL does not identify the content |
| In-process | No network | Per-server copies disagree, cold start per server | The data must be identical on every server |
| Distributed (Redis) | One copy, deletable | Network hop, another system to run | The extra hop outweighs the saving |
| Database | Automatic | Not under your control, shared by every query | You expect it to replace an application cache |
In practice: the ShopStream product page when a price changes
This is the realistic example, an educational scenario on the course's running example, ShopStream. It describes no real company, and every number other than the shared ShopStream figures is illustrative.
This is a realistic scenario, not a case history.
The situation
ShopStream peaks at about 2,300 browsing requests per second (the shared estimate from the project brief), most of them product pages and product API calls over a catalogue of 100,000 products. Stores change prices through the day. The team wants the database to see a small fraction of the traffic, but a shopper must never pay a price different from the one at checkout, and a price change should reach the page within about a minute.
Starting architecture
Figure 6. The design under discussion, with the hit ratios as assumptions. How to read this: the numbers on the nodes are not measurements. They show how the layers multiply: 2,300 x 0.4 = 920 requests per second reach the app, and 920 x 0.1 = 92 reach PostgreSQL.
Options considered
| Option | Staleness after a price change | Load on the origin | Risk |
|---|---|---|---|
A. Browser max-age=300, no CDN purge |
Up to 5 minutes, and nothing can shorten it | Lowest | Users see an old price for minutes, and no purge can reach them |
B. CDN s-maxage=60, browser max-age=0 with validation, Redis delete on write |
About 60 s worst case | Moderate | A stale price on the page for up to a minute |
| C. No CDN caching for product data | 0 s from the edge | Highest: all 2,300 req/s reach the app | The origin carries the whole peak |
The decision
Option B. Product JSON is the same for every shopper, so it is safe in a shared cache. The edge gets a one-minute life, the browser revalidates each time, and the application deletes the Redis key in the code path that commits the price. Checkout never reads the cached price: it re-reads the database and uses that, so a stale page cannot change what a shopper pays. The response headers:
Cache-Control: public, max-age=0, s-maxage=60
Vary: Accept-Encoding
public, max-age=0, s-maxage=60 lets the shared cache keep the response for 60 seconds while the browser treats its own copy as immediately stale and revalidates it (the app adds an ETag so that revalidation can end in a cheap 304). The cart and account endpoints get Cache-Control: private, no-store, so they never reach the shared layer.
Rollout and verification
A rollout would add the headers behind a flag for one route first and compare three numbers against the previous week: the CDN's hit ratio, requests reaching the app, and PostgreSQL queries per second. A price-change test would change a price and poll the page until it shows the new value (expected: about 60 seconds at most, plus any purge time). A second test would request an authenticated URL as two users and assert the bodies differ.
The 2 a.m. failure
The page reads: support tickets say shoppers saw a price of 3.99 on the product page and 3.49 at checkout, for several minutes after a store's price cut. The checks: the response headers on the product URL (did someone ship max-age=300?), the CDN's reported age for the object, and whether the Redis delete ran (a failed delete would leave a 600-second entry behind). The mitigation is a purge of the product URL on the CDN and a manual delete of the key. The lasting fix is to put the delete and the purge in the same retried, observable step as the price commit, to keep the Redis TTL as a safety net, and to alert when the age of a served product response exceeds the budget. Checkout used the database price, so no shopper was charged the wrong amount: the page was stale, but the invariant held.
Architect's notes
- Write down a staleness budget per resource in seconds, then choose lifetimes that sum to less than it for the layers you cannot purge.
- Prefer URL versioning to invalidation for anything static: a new URL needs no purge at any layer.
- Give the shared layers the shortest list of content. Everything personalised stays out by default; add exceptions deliberately.
- Measure each layer separately. A CDN hit ratio, a Redis hit ratio and database load tell different stories, and the layers behind a busy one see a colder stream.
- Make the cache safe to lose. If clearing a layer breaks correctness, it is acting as a database.
- Check provider behaviour. TTL clamps,
Agehandling, purge delays and stale-serving differ between CDNs and versions.
Common mistakes
- Sending no
Cache-Controlat all, so a cache may apply a heuristic lifetime of its own. - Caching a personalised response in a shared layer.
privateorno-store, and a correctVary, keep it out. - Long browser lifetimes on mutable URLs. You cannot purge the browser.
- Relying on a CDN to hold an object until its TTL. It may evict earlier.
- Assuming
no-storeis a security control. The RFC says it is not. - Stacking layers without adding up the TTLs. A lost invalidation leaves the longest TTL in force.
- Expecting the database to be your result cache, or caching exact data used at the moment of decision, such as the price at payment.
Key takeaways
- A cache layer is defined by who controls it: the closer to the user, the faster the hit and the less control you have.
- Client caches are private and cannot be purged; CDN caches are shared and purgeable through the provider; application caches are yours; the database's caches are the engine's.
- HTTP headers steer the first two layers, with a fixed order for computing the freshness lifetime.
- Layers multiply their savings, and a deeper layer sees a colder, flatter stream.
- Staleness is the sum of lifetimes where age is not shared and roughly the longest where it is; keep a TTL on every entry.
- A personalised response in a shared cache is a data leak; mark it
privateorno-store.
Practice exercises
Exercise 1 (30 min). For six ShopStream responses (home page, product images, product JSON, cart summary, order history, a public store list), choose the Cache-Control header and say which layers may store it.
Exercise 2 (30 min). Compute the load reaching PostgreSQL if the CDN hit ratio is 80 percent and the Redis hit ratio is 70 percent at 2,300 requests per second. Then explain why the Redis hit ratio could fall once the CDN hit ratio rises.
Exercise 3 (45 min). With max-age=120, s-maxage=300 and a Redis TTL of 900 seconds, state the worst-case staleness after a commit in each of the three situations in the worked example. Say which assumption changes the answer most.
Exercise 4 (1 hour). Design the invalidation path for a price change: the commit, the Redis delete, the CDN purge and the retries. State what happens if the purge fails, and what bounds the staleness then.
Exercise 5 (2 hours). Choose any API you know and write a test that requests a personalised URL as two users and checks the bodies differ and the response headers contain private or no-store. Decide where in a deployment pipeline it should run.
Related reading
- Caching Basics: why a cache works, cache-aside, TTL and eviction.
- Advanced Caching: write policies, invalidation, stampedes, hot keys, consistent hashing.
- CDN and Object Storage: how a CDN works, cache keys, versioned URLs and cost.
- ShopStream Stage 2: Scaling Reads: layered caches, Redis and a CDN on the course's running example.
- Estimation: sizing a cache and a read path.
Official documentation
- IETF, RFC 9111: HTTP Caching (June 2022): private and shared caches, freshness lifetime,
Age,Cache-Control,Vary. - Amazon Web Services, Manage how long content stays in the cache (expiration): CloudFront TTLs,
s-maxage, stale directives, eviction. - Redis, Client-side caching reference: tracking, broadcasting, invalidation and the failure cases.
- Redis, Key eviction: what the application cache does when memory is full.
- PostgreSQL 18 documentation, Resource Consumption (
shared_buffers) and Query Planning (effective_cache_size). - MySQL Reference Manual, The InnoDB Buffer Pool (8.4) and What Is New in MySQL 8.0 (query cache removal).