How HTTP Caching Actually Works
Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)Most explanations of HTTP caching stop at a slogan: store the response so the next request is faster. That is not wrong, but it is so incomplete that it misleads people in production.
HTTP caching is not a bag of bytes sitting somewhere between a browser and a server. It is a contract about when one representation may be reused for another request, how long that reuse stays valid, which request fields change the identity of the representation, and what a cache must do once freshness runs out. If you understand those rules, cache behaviour starts to look mechanical. If you do not, caches look random, and incidents get blamed on "the CDN" or "the browser cache" as if those were personalities rather than implementations of RFC 9111.
That contract matters because the web depends on it. A cold page load from Athens to an origin in Frankfurt pays for DNS, TCP, TLS, request transmission, origin processing, and object delivery. A warm cache hit may collapse all of that into a few microseconds of lookup in the browser or a short RTT to a nearby edge cache. The difference is the difference between a site that feels immediate and a site that drags. It is also the difference between an origin serving 2,000 requests per second and an origin serving 80.
This article follows the mechanism all the way through: what an HTTP cache actually stores, how it builds a cache key, how freshness is calculated, how validators turn a stale object into a cheap metadata check, why Vary is both essential and dangerous, and which failure modes keep showing up in real systems.
1. A Cache Is a Contract About Reuse
The first thing to fix is the mental model. An HTTP cache is not just "a saved response". It is a component that receives a request, decides whether it already holds a representation that matches that request, decides whether that representation is still fresh enough to serve, and if not, decides whether it can cheaply revalidate the representation with the origin.
Several different caches may participate in a normal page load:
- the browser's private cache
- a service worker cache if the application uses one
- a forward proxy in a corporate network
- a shared reverse proxy or CDN edge cache
- a shield cache behind the CDN edge
All of them speak roughly the same HTTP language, but they do not have the same policy. A browser cache is private to one user agent. A CDN cache is shared across many users and has to be much more careful about personalisation. A browser may reuse a private response. A shared cache must not.
That is why the basic questions are always the same:
- does this request map to a stored representation?
- if yes, is that representation still fresh?
- if not fresh, can I validate it conditionally?
- if validation succeeds, can I refresh metadata and keep using the stored body?
- if validation fails, do I need a full new response body?
Those questions are stricter than most people think. Reuse is only legal when the cache knows that the stored response corresponds to the new request. If content varies by Accept-Encoding, language, cookies, or some other request field, then reusing a saved response without checking those dimensions is a correctness bug, not a performance optimisation.
This is why caching feels unforgiving. It sits exactly on the boundary between performance and correctness. A database index that misses usually makes something slower. A cache that is keyed or validated incorrectly can serve the wrong bytes to the wrong user.
A useful way to think about HTTP caching is this:
- identity answers whether two requests refer to the same cacheable representation
- freshness answers whether the stored representation is still valid to reuse without contacting origin
- validation answers whether a stale stored representation still matches the origin's current version
- scope answers which caches are allowed to keep and serve the representation
Everything else is detail built on top of those four ideas.
2. What a Cache Actually Stores
A cache does not only store a response body. It stores metadata that allows later requests to be matched and judged.
Suppose the origin returns this response for a stylesheet:
HTTP/1.1 200 OK
Date: Fri, 01 May 2026 10:00:00 GMT
Cache-Control: public, max-age=86400
Content-Type: text/css; charset=utf-8
Content-Encoding: br
Vary: Accept-Encoding
ETag: "build-19f3a2"
Content-Length: 4821
...brotli-compressed body...A conforming cache that stores this response usually keeps at least:
- the response body bytes
- the status code
- response headers relevant to reuse
- the time the response was received
- whatever request information is needed for the cache key
- validator metadata such as
ETagorLast-Modified - freshness metadata derived from
Date,Age,Cache-Control, orExpires
That last point matters. Freshness is not usually stored as a simple "expires at 10:14" sticker unless the cache chooses to derive such a number internally. The protocol gives a set of fields from which freshness can be calculated.
A cache also has to remember the request dimensions that made this response distinct. Here Vary: Accept-Encoding means the brotli-compressed representation is not interchangeable with a gzip or identity representation. If another client later asks for the same URL but does not advertise brotli support, this cached object is not automatically a match.
Different implementations store different amounts of extra state. A browser may record whether the response came from normal navigation or a subresource fetch. A CDN may store object size, purge tags, shield-populated state, or local eviction priority. Those are implementation details. The protocol-level point is that a cache needs enough state to answer identity, freshness, and validation correctly.
There is also an important distinction between a representation and a resource. The same logical resource might have several representations:
- compressed vs uncompressed
- English vs Greek
- mobile image format vs desktop image format
- authenticated personalised response vs anonymous public response
What the cache stores is one representation. If your application produces several valid variants, the cache needs a way to distinguish them. That is why Vary exists, and why careless query parameter normalisation or header stripping can turn a cache into a cross-user data leak.
Caches may also decline to store a response at all. no-store says exactly that. Some implementations may also refuse to store very large bodies, streaming responses, partial objects, or responses whose policy is ambiguous. Operators often think of caching as opt-in magic. Implementations think of it as a series of safety checks.
3. The Cache Key Decides Whether Reuse Is Even Legal
Before freshness matters, the cache has to know whether it has the right object.
The naïve cache key is just the URL:
scheme + host + path + querySometimes that is enough. It is often not.
Consider these two requests:
GET /docs/guide HTTP/1.1
Host: example.eu
Accept-Encoding: br, gzip
Accept-Language: en-GB
GET /docs/guide HTTP/1.1
Host: example.eu
Accept-Encoding: gzip
Accept-Language: el-GRIf the origin emits English brotli bytes for the first request and Greek gzip bytes for the second, then both URL requests refer to the same resource but not the same representation. Serving one response body for both requests would be wrong.
HTTP solves this by allowing the origin to declare which request headers affect representation selection:
Vary: Accept-Encoding, Accept-LanguageA cache then has to incorporate those fields into matching. In practice the cache key becomes conceptually closer to:
scheme + host + path + query + selected request header values named by VaryThat sounds simple. The danger is that Vary is both mandatory for correctness and expensive for hit ratio.
If you forget a necessary Vary, you can serve the wrong representation. If you include too much variation, cache efficiency collapses.
A Correct Vary Example
Compression is the classic case:
Cache-Control: public, max-age=86400
Content-Encoding: gzip
Vary: Accept-EncodingWithout Vary: Accept-Encoding, a shared cache might store a gzip response for one client and later hand those bytes to a client that did not ask for gzip. Some modern browsers are permissive enough that you may not notice immediately. Some non-browser clients will fail loudly. Either way, it is a bug.
A Dangerous Vary Example
Now consider this:
Vary: User-AgentThat is legal, but often disastrous. Real-world User-Agent values are highly diverse. If a CDN keys on the full header, one page may fragment into thousands of near-unique variants. The cache becomes almost useless.
Historically people reached for Vary: User-Agent to split mobile and desktop content. Modern practice is usually better served by responsive design, a narrow device-class header, or edge logic that maps a huge client surface into a small stable set of variants.
Query Strings and Unkeyed Inputs
Caches also get into trouble when applications vary response content on inputs that are not reflected in the cache key.
Example:
GET /pricing?currency=EUR
GET /pricing?currency=CHFIf the cache ignores the currency parameter, Swiss franc pricing can leak into euro responses. If it keys on every analytics parameter, such as utm_source, the cache fragments needlessly.
That is why serious reverse proxies and CDNs often allow custom cache-key rules: include these query parameters, ignore those, normalise this header, drop that cookie. Those features are powerful because the core problem is powerful. The cache key has to match the application's actual variation model.
The operational lesson is blunt: most caching bugs are keying bugs. People stare at TTLs and forget that a perfect freshness policy still fails if the wrong request matches the wrong object.
4. Freshness Is an Age Calculation, Not a Feeling
Once a cache has found a matching stored representation, it has to decide whether that representation is still fresh.
Freshness is not "I fetched this a while ago and it is probably fine." It is an age calculation grounded in HTTP metadata. RFC 9111 defines the model. The main pieces are:
Date: when the origin says it generated the responseAge: how long the response has already spent in cache before reaching this cacheCache-Control: max-age=N: fresh forNseconds after generationCache-Control: s-maxage=N: fresh forNseconds in shared cachesExpires: an older absolute-time freshness model
A simple example:
Date: Fri, 01 May 2026 10:00:00 GMT
Cache-Control: public, max-age=600If a cache receives this at 10:00:02 and serves it again at 10:07:00, the object is still fresh. If it serves it at 10:15:00, it is stale.
The catch is that real caches do not only use wall-clock difference between now and Date. They have to account for transit delays and upstream age. RFC 9111 describes this with a few calculations:
apparent_age = max(0, response_time - date_value)
corrected_age_value = age_value + response_delay
corrected_initial_age = max(apparent_age, corrected_age_value)
current_age = corrected_initial_age + resident_timeYou do not need to memorise the variable names to understand the intent. The cache is trying to answer one honest question: how old is this representation really, including time it may already have spent elsewhere?
Why Age Matters in Shared Cache Chains
Imagine an origin in Amsterdam, a shield cache in Frankfurt, and a CDN edge in Athens.
- The shield fetches the object from origin.
- The shield keeps it for 250 seconds.
- The Athens edge later fetches from the shield.
If the shield forwards:
Age: 250
Cache-Control: public, max-age=300then the edge only has 50 seconds of freshness left, not a fresh new 300-second window. Without Age, each layer could accidentally rejuvenate the object and keep it alive far longer than the origin intended.
s-maxage Overrides Shared Cache Freshness
Shared caches often need different policy from browsers.
Example:
Cache-Control: public, max-age=60, s-maxage=300This means:
- browsers may treat the object as fresh for 60 seconds
- shared caches may treat it as fresh for 300 seconds
That split is common for HTML or API responses where you want end users to re-check fairly often but still want the CDN to absorb traffic for longer.
Expires Still Exists, but Cache-Control Wins
Old HTTP relied heavily on Expires:
Expires: Fri, 01 May 2026 10:10:00 GMTThat still works, but Cache-Control is the modern, more precise mechanism. When both exist and disagree, caches generally follow Cache-Control. Expires also depends on clock correctness in a way that relative directives like max-age handle more cleanly.
Heuristic Freshness Exists, but It Should Not Be Your Plan
If a response has no explicit freshness information but does include Last-Modified, some caches are allowed to invent a heuristic lifetime, often a fraction of the object's apparent age since modification. Browsers have done this for years.
That should not comfort you. Heuristic freshness exists because the web has legacy content, not because it is a best practice. If you care about behaviour, send explicit policy.
5. Revalidation Turns a Full Download into a Metadata Check
Stale does not automatically mean unusable.
When a cached representation goes stale, the cache can often ask the origin a conditional question instead of downloading the body again. That is revalidation.
The two main validator families are:
ETagwithIf-None-MatchLast-ModifiedwithIf-Modified-Since
Here is the ideal path.
First Response
HTTP/1.1 200 OK
Date: Fri, 01 May 2026 10:00:00 GMT
Cache-Control: public, max-age=60
ETag: "post-4821"
Content-Type: text/html; charset=utf-8
<html>...</html>The cache stores body and validator.
Later Request After Freshness Expired
The cache sends a conditional request:
GET /news/europe HTTP/1.1
Host: example.eu
If-None-Match: "post-4821"If the origin's current representation is unchanged, it replies:
HTTP/1.1 304 Not Modified
Date: Fri, 01 May 2026 10:02:10 GMT
Cache-Control: public, max-age=60
ETag: "post-4821"No response body is needed. The cache refreshes metadata and keeps using the stored body.
This is one of the biggest practical wins in HTTP. A stale object can often be revalidated with a few headers instead of a full payload. That saves origin CPU, bandwidth, and latency.
ETag vs Last-Modified
Last-Modified is simpler but less precise:
Last-Modified: Fri, 01 May 2026 09:55:00 GMTLater:
If-Modified-Since: Fri, 01 May 2026 09:55:00 GMTThis works well for coarse-grained content updates. It is weaker when multiple changes can happen within one second, or when generation time does not map neatly to modification semantics.
ETag is a server-defined opaque validator. It can represent a content hash, a build ID, a revision number, or another stable representation identifier. The client must not assume how it was made. It only needs to send it back.
Strong and Weak ETags
Not all ETags are equal.
ETag: "a81d9c"
ETag: W/"a81d9c"A strong ETag means byte-for-byte equality. A weak ETag means semantic equivalence but not necessarily identical bytes.
Why does this matter?
- strong ETags are suitable when exact byte identity matters
- weak ETags are suitable when the content is "the same enough" for cache validation, even if formatting or transfer details changed
- range requests require strong validation semantics because byte offsets must line up precisely
If your server generates weak ETags for HTML that only changes in whitespace or compression, that may be fine. If you expect partial-content resumability, weak validators are insufficient.
If-None-Match Takes Priority
If both validators exist, If-None-Match is the more precise mechanism and generally takes precedence over If-Modified-Since. That is one reason many systems emit both: modern caches use ETags, while older or simpler clients may still rely on modification dates.
Revalidation Updates Policy Too
People sometimes think a 304 Not Modified only says "body unchanged". It also refreshes policy metadata. If the server changes Cache-Control, Expires, or validator fields on the 304 response, the cache updates its stored metadata accordingly.
That means revalidation is not only about saving bytes. It is also how the origin can adjust future cache behaviour without retransmitting the full object.
6. Cache-Control Directives Are Policy Knobs With Sharp Edges
Cache-Control is where most practical policy lives. It is also where the naming misleads people.
Here is the short version that operators actually need:
| Directive | What it really means |
|---|---|
public | Shared caches may store the response if other rules allow it. |
private | A browser may store it, but shared caches must not reuse it for multiple users. |
no-store | Do not store the request or response at all. |
no-cache | Storage is allowed, but reuse requires revalidation before every serve. |
max-age=N | Fresh for N seconds. |
s-maxage=N | Shared-cache freshness override. |
must-revalidate | Once stale, do not serve it without successful validation. |
immutable | While fresh, the client should not bother revalidating on reload because the representation will not change. |
stale-while-revalidate=N | A cache may serve a stale response briefly while refreshing it in the background. |
stale-if-error=N | A cache may serve stale content briefly if the origin errors during refresh. |
A few of these deserve special care.
no-cache Does Not Mean "Do Not Cache"
This is the classic trap.
Cache-Control: no-cacheThis does not forbid storage. It means the cache must validate before reuse. In other words, keep the body if you like, but do not serve it blindly.
That is often exactly right for HTML. You still get validator-based savings, but users do not sit on an old page for long.
no-store Is Much Stronger
Cache-Control: no-storeThis tells caches not to store the response at all. That can be appropriate for highly sensitive material such as a banking statement page or a one-time token response. It also removes any possibility of validator-based re-use. If you put no-store on everything, you are giving up a major part of HTTP's efficiency model.
private Is for User-Specific Responses
Suppose /dashboard contains Anna's account balance. The browser may store that page locally for Anna, but a shared CDN cache must not hand it to Nikos.
That is exactly what private expresses:
Cache-Control: private, max-age=60The response may still be cached, just not in shared multi-user caches.
immutable Is for Versioned Assets
This is ideal for fingerprinted files:
Cache-Control: public, max-age=31536000, immutableIf the file is app.3f2c9a.js, the URL changes when the bytes change. Revalidation during freshness becomes wasted work. immutable tells the client not to second-guess a fresh copy.
stale-while-revalidate Hides Latency, Not Consistency Errors
Cache-Control: public, max-age=300, stale-while-revalidate=30After 300 seconds the object becomes stale, but for another 30 seconds the cache may serve that slightly stale copy immediately while it refreshes in the background.
This is excellent for content where 30 extra seconds of age is acceptable. It is a terrible idea for stock prices, one-time authorisation decisions, or rapidly changing personalised state.
Every directive is a correctness tradeoff first and a performance knob second.
7. Browser Caches and Shared Caches Do Not Play the Same Role
People often talk about "the cache" as if one component made all reuse decisions. In reality the browser cache and a CDN edge cache sit in different places, see different traffic, and care about different failure modes.
The Browser Cache
A browser cache is private to one user agent. It can safely keep user-specific JavaScript bundles, images fetched behind a session, or a private HTML response for a short period. It is mainly trying to save network round trips for one person.
The Shared Cache
A shared cache is a multi-user reuse layer. It is trying to absorb repetitive traffic across many clients while never confusing one user's view with another's. That makes scope control far stricter.
This difference explains why s-maxage exists and why private matters.
Consider this response:
Cache-Control: private, max-age=120A browser may reuse it for two minutes. A CDN must not serve it across users.
Now consider this:
Cache-Control: public, max-age=30, s-maxage=300, stale-while-revalidate=30The browser gets short freshness, but the CDN can shield the origin for five minutes and even mask revalidation latency briefly.
Cookies Complicate Shared Caching
Many origin systems attach cookies to HTML by default, even when the page is identical for anonymous users. Some caches become conservative as soon as they see Set-Cookie. Others can be configured to ignore irrelevant cookies or separate anonymous from authenticated traffic explicitly.
This is one reason CDN migrations sometimes disappoint. The origin is technically sending cacheable content, but every response also carries analytics, A/B, or session cookies, so the shared cache declines to reuse it safely.
Authorisation Headers Need Deliberate Policy
A request with Authorization: Bearer ... is another caution point. Shared caches must be careful, because blindly caching authenticated responses is an obvious data leak risk. There are standards for making some authenticated responses cacheable, but the default operator instinct should be caution.
CDNs Often Add Non-Standard Controls
Many CDN products support extra response headers such as Surrogate-Control, cache tags, or vendor-specific bypass rules. Those are real and useful, but they are outside the core HTTP standard model. The important thing is to keep the layers straight:
- origin emits HTTP semantics that browsers and generic caches understand
- CDN may also honour CDN-specific policy for shared-cache behaviour
If you rely on vendor extensions, make sure the baseline HTTP behaviour is still sane. Otherwise the system becomes impossible to reason about when traffic bypasses that specific edge path.
One more subtle difference between browser and shared caches is that human actions in the UI do not map to one universal protocol behaviour. A normal navigation may allow the browser to reuse a fresh object silently. A soft reload often tells the browser to check whether the object is still current. A hard reload may bypass some local cache state entirely and force a network fetch. Developers then open DevTools, hit refresh three different ways, and conclude that caching is inconsistent when in fact the user agent is following three different instructions.
That matters during debugging. If you are trying to understand origin policy, you want to know whether the browser made an ordinary navigation, a conditional revalidation, or a deliberate bypass. If you are trying to understand CDN behaviour, you want to know whether the edge received a normal request or a cache-busting reload from one impatient engineer. The wrong test method produces the wrong conclusion.
It is also why shared-cache observability matters. A browser can hide the network completely when its private copy is fresh. The CDN may still show no traffic at all because the request never left the machine. Operators who only inspect edge logs can miss perfectly valid browser-side reuse. Operators who only inspect browser waterfalls can miss the fact that every stale browser request is still being absorbed cleanly by a shared cache two milliseconds away.
Caching is layered. Good debugging has to be layered too.
8. Deployment Strategy Matters More Than Clever Headers
The strongest caching setups are not clever because they squeeze heroic behaviour out of one header combination. They are strong because the application architecture matches the cache model.
The best-known pattern is still the right one:
- HTML gets short lifetimes and validators
- static assets get versioned filenames and very long immutable lifetimes
- APIs get policy based on whether the data is public, shared, or user-specific
Versioned Static Assets
For JavaScript, CSS, fonts, and large images, the clean strategy is URL fingerprinting.
/app.3f2c9af1.js
/styles.8b7e4c11.css
/hero.1d3320b4.avifThen send:
Cache-Control: public, max-age=31536000, immutableThis works because invalidation happens by changing the URL, not by trying to force every cache in Europe to forget yesterday's bytes instantly.
HTML as a Discovery Document
HTML is different. It points the browser at the next generation of assets, so it cannot usually stay stale for long. A common pattern is:
Cache-Control: public, max-age=0, must-revalidate
ETag: "page-4821"or:
Cache-Control: no-cache
ETag: "page-4821"That keeps the HTML reusable only after validation. Users do not download the whole page again if it has not changed, but they also do not sit on a stale shell for hours.
Public API Responses
Some APIs are safely shareable.
Example: exchange rates updated every five minutes.
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "rates-20260501T1000Z"Here a browser sees one-minute freshness, while the CDN shields origin for five minutes. Brief stale serving during revalidation is acceptable because five-minute rate snapshots are already a bounded compromise.
Personalised API Responses
Other APIs are not shareable.
Example: current account overview.
Cache-Control: private, no-cache
ETag: "user-731-overview-v994"This allows the browser to keep a copy and revalidate it, but shared caches stay out.
Purge Is a Control-Plane Feature, Not a Design Strategy
Operators love purge buttons because they feel decisive. Purge is necessary, but it should not be the primary content versioning strategy for static assets. Global invalidation is a distributed systems problem. It can be fast, but it is never free.
If every deployment depends on purging dozens of shared objects instantly across Athens, Paris, Milan, and Frankfurt, you are building on a more fragile foundation than content-addressed asset URLs.
Use purge for:
- bad HTML that must disappear quickly
- accidentally cached sensitive content
- editorial rollbacks
- emergency response
Do not use purge as a substitute for immutable asset naming.
9. Most Cache Incidents Come From Keying Mistakes, Not From TTL Alone
When caching breaks, the symptom is often "stale content". The cause is often something else entirely.
Personalised Data Served as Public
This is the nightmare class.
Example:
Cache-Control: public, max-age=300
Set-Cookie: session=...If the page content depends on the authenticated user but the response is marked public, a shared cache may store and reuse private data across users. Good CDNs add guard rails. They cannot fix every bad policy decision.
Missing Vary
The origin sends compressed content but forgets:
Vary: Accept-EncodingA cache now believes gzip and identity responses are interchangeable. Similar bugs happen with language selection, image format negotiation, or device-specific rendering.
Unkeyed Inputs and Cache Poisoning
Suppose the application reflects a request header into the response, but the cache key ignores that header.
Example:
GET /page
X-Forwarded-Host: evil.exampleIf the application builds absolute URLs or canonical tags from that header and the cache stores the output under the ordinary /page key, one malicious request can poison the cached response for later users.
This is a large class of web-cache poisoning issues. The details vary, but the shape is stable:
- attacker finds a request input that changes the response
- cache key does not include that input
- attacker causes cache to store a corrupted or attacker-controlled variant
- normal users receive poisoned content
The fix is not "reduce the TTL". The fix is to stop response variation on untrusted unkeyed inputs, or to include the relevant input in the key.
Vary Explosion
On the other side, operators sometimes add Vary to everything and accidentally erase the cache.
Vary: User-Agent, Accept-Language, Accept, Cookie might be technically correct for a particularly chaotic origin. It is also a sign that the application has not been simplified into a cache-friendly variation model.
Clock and Validator Bugs
Caches trust metadata. If the origin emits a Date far in the future because the server clock is wrong, freshness can be distorted. If ETags are inconsistent across equivalent origin nodes, revalidation loses value. If Last-Modified is updated on every render even when content does not change, 304 hit rates fall for no good reason.
Range Requests With Weak Validators
Large media delivery and resumable downloads expose another edge. If the server emits only weak validators, caches and clients cannot rely on byte-for-byte identity for range assembly. That is not an academic detail when users are resuming a 1.6 GB software image.
The common thread is precision. Caches are obedient. They do exactly what the metadata and keying rules say, even when those rules are poor.
10. Trace One Object Through Miss, Hit, Revalidate, and Purge
It helps to watch one object move through the system.
Assume a news site whose HTML lives in Amsterdam, behind a CDN with a shield in Frankfurt and an edge in Athens.
The page policy is:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "home-20260501-1000"
Vary: Accept-EncodingRequest 1: Cold Miss
A reader in Athens requests / at 10:00:03.
- browser cache has nothing
- Athens edge has nothing
- Frankfurt shield has nothing
- origin generates HTML in Amsterdam
Response flows back:
HTTP/1.1 200 OK
Date: Fri, 01 May 2026 10:00:03 GMT
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "home-20260501-1000"
Content-Encoding: br
Vary: Accept-EncodingThe browser stores it. Athens stores it. Frankfurt stores it.
Request 2: Browser Fresh Hit
The same browser reloads at 10:00:40.
- browser age is about 37 seconds
max-age=60, so the object is still fresh- request is satisfied locally
No network request is required.
Request 3: Browser Stale, Edge Fresh
Another reload happens at 10:02:10.
- browser copy is now stale because 127 seconds have passed
- browser revalidates or refetches according to its policy
- Athens edge still sees shared-cache freshness because
s-maxage=300
So the edge can answer directly. It may return:
Age: 127The browser gets a fast network response from a nearby cache rather than a trip to Amsterdam.
Request 4: Shared Cache Revalidation
A new request comes at 10:05:20.
- browser stale
- Athens edge stale because age exceeds 300 seconds
- edge uses validator
Conditional request from edge toward shield or origin:
GET / HTTP/1.1
Host: news.example.eu
If-None-Match: "home-20260501-1000"
Accept-Encoding: br, gzipIf the page did not change:
HTTP/1.1 304 Not Modified
Date: Fri, 01 May 2026 10:05:20 GMT
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "home-20260501-1000"The cached body stays. Metadata is refreshed. The client never pays for a full HTML body transfer.
Request 5: Origin Changed
At 10:07:00 the newsroom publishes an update. The next conditional revalidation receives:
HTTP/1.1 200 OK
Date: Fri, 01 May 2026 10:07:02 GMT
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
ETag: "home-20260501-1007"New body, new validator, new freshness window.
Emergency Purge
At 10:08:10 editors notice a legal error in the homepage headline. They do not want to wait for the five-minute shared freshness window to drain naturally. They issue a purge for /.
The purge control plane tells caches to drop the stored object. The next request is a miss or a re-fill from an upstream layer, depending on propagation timing.
This is what people mean when they say caching is fast until it is suddenly operational. The hit path is simple. The correctness path depends on policy, validators, layered age handling, and sometimes distributed invalidation.
The operational trick is to remember which layer did the work. A browser hit saved an RTT entirely. A shared-cache hit still used the network, but only to the nearest edge. A successful revalidation still touched origin policy, but not the full body. Those are three different wins, and production tuning usually needs all three.
11. HTTP Caching Rewards Precise Thinking
The web got fast by making reuse normal rather than exceptional. HTTP caching is the protocol machinery that makes that possible without turning the whole system into a stale-content lottery.
A good cache configuration is usually boring in the best sense. Static assets have stable versioned URLs and long immutable lifetimes. HTML and mutable APIs use validators and measured freshness windows. Shared caches only store what is actually shareable. Vary describes real representation differences and nothing more. Purge exists for incidents, not as the main deployment mechanism.
A bad cache configuration usually looks fast right up until it leaks data, serves the wrong language, ships broken JavaScript after deploy, or keeps hammering the origin because every response is technically uncacheable.
The reason this topic keeps confusing teams is that caching is not one feature. It is several small mechanisms that have to align: keying, freshness, validation, scope, and invalidation. Once those pieces click, the behaviour stops looking mysterious. An object is either the right representation or it is not. It is either fresh or it is not. It either validates or it does not.
That is the whole game. The hard part is being precise enough that the cache can play it correctly.