The customer still sees the old price: cache layers, Cache-Control and invalidation
When an old price stays on the screen, it is almost never a single cache misbehaving. It is four separate copies of the same thing, held by layers that know nothing about each other: the browser's private cache, the CDN's shared cache, a key-value cache inside your application, and whatever sits in front of the database. Each has its own key, its own lifetime and its own way of being cleared. That is why "let's clear the cache" is not a fix but a symptom of not knowing which layer is holding the copy. What follows is how to find that layer, what to write into each one, and where caching quietly turns into a data leak.
Find out which layer is holding the copy
The browser cache is private. It belongs to one user and you cannot reach into it. The CDN cache is shared: whatever it hands to one visitor it will hand to the next, but you can delete it with an API call. The application cache is entirely yours to control and usually the least documented of the three, because it tends to start life as "we put a Redis in front of that query" and nobody ever writes down the TTL.
Headers settle the question quickly. Run curl -sI https://yoursite.com/product/123. If the response carries an Age header, it came from an intermediary cache and the value tells you how many seconds that copy has been sitting there. Behind Cloudflare you also get cf-cache-status with values like HIT, MISS or EXPIRED. If neither header is present and the content is still stale, the copy is either in the browser or inside your own application. Opening the same URL in a private window separates those two in about five seconds.
no-cache does not mean "do not cache"
This is the most common caching mistake we run into. The current specification is RFC 9111, published in June 2022, and the directives mean specific things. no-store says no cache of any kind may store the response. no-cache allows storage but requires validation with the origin before every reuse, so a response marked no-cache does sit on disk, it just never gets served blindly. must-revalidate is narrower: reuse freely while fresh, validate the moment it goes stale. private restricts storage to the browser, public opens it up to shared caches as well.
Then there are the lifetime directives. max-age counts seconds from when the response was generated, not when it arrived, and an intermediary reports elapsed time through the Age header, which the browser subtracts from the remaining freshness. s-maxage applies only to shared caches and overrides max-age for them. Three recipes cover most of what a normal product needs:
- Content-hashed static assets:
public, max-age=31536000, immutable. Theimmutabledirective comes from RFC 8246 and tells the browser the file will not change while fresh, which removes the pointless revalidation requests you otherwise get on every reload. - HTML and fast-moving JSON:
no-cacheplus anETag. The copy is kept, revalidated each time, and returns a 304 when nothing changed. - Anything behind a login:
private, no-store. Do not be clever here.
If you write no header, something else decides for you
With no Cache-Control, caches fall back to heuristic freshness, and heuristics are not something you can plan around. CDNs apply their own defaults too. Cloudflare is a well documented example: it does not cache HTML or JSON by default, but it does cache a fixed list of extensions covering images, scripts, styles, fonts and documents. When a response arrives with no cache headers at all, the edge invents a TTL based on status code: 120 minutes for 200, 206 and 301, 20 minutes for 302 and 303, 3 minutes for 404 and 410. It also declines to cache when Cache-Control is private, no-store, no-cache or max-age=0, or when the response carries a Set-Cookie header.
The rule that falls out of this is short. State what you want on every route. On any endpoint where you stay silent, the behaviour is decided by your CDN's defaults and the browser's guesswork, and neither of them knows your business rules.
Validation is cheap, not free
ETag and Last-Modified carry a fingerprint of the content. On the next request the browser sends it back with If-None-Match, and if nothing changed the server answers 304 with no body. The saving is bandwidth, not latency: the request still goes over the network and still occupies your server. On a slow mobile connection, a 304 is expensive too.
Two traps are worth checking for. First, if different server instances produce different ETag values for the same content, validation fails every time and every request re-downloads the full response. Behind a load balancer you will not notice this without measuring it. Second, check whether your compression layer rewrites or drops the ETag. Both failures are silent, and both show up in a single curl round.
If you do not design the cache key, your hit rate will suffer
A cache stores a response against a key. By default the key is the URL, and the Vary header extends it. Vary: Accept-Encoding is reasonable, because compressed and uncompressed bodies really are different objects. Vary: User-Agent shatters the key into thousands of variants and destroys the hit rate. A response with Vary: * can never be reused for a later request, which means you have turned caching off without meaning to.
Query strings do the same damage. Every visitor arriving with ?utm_source= creates a separate copy of the same page, which is usually the answer to why origin load doubles on the day a campaign goes live. Cache key configuration exists for exactly this: drop tracking parameters from the key, normalise their order, and include only the cookies that genuinely change the response. For language selection, giving each language its own URL beats Vary: Accept-Language on both the cache and the search side, which we covered in the post on multilingual site architecture.
Three ways to invalidate: wait, delete, or change the key
The first is to wait out the TTL. Short TTLs look like a universal answer but are not: a 60 second lifetime means your origin absorbs a full load of traffic every minute. The second is targeted deletion. Purging by URL works at small scale, but when a product changes and every listing and category page that mentions it goes stale with it, enumerating URLs stops being practical. Cache tags solve that: attach a header such as Cache-Tag: product-123, category-9 to the response, then purge every copy carrying that tag with one call. Cloudflare documents the limits: the Cache-Tag header cannot exceed 16 KB in total, roughly 1,000 unique tags, a single tag in an API call is capped at 1,024 characters, and you can purge up to 100 tags at a time.
"Purge everything" is an emergency button. The moment you press it, your origin starts absorbing all the traffic the CDN had been shielding it from. Hitting it during peak hours is a self-inflicted load event.
The third approach is the sturdiest: do not invalidate, change the key. Name static files after a hash of their contents (app.9f2c1d.js) and a new build becomes a new URL, so keeping the old one cached for a year costs nothing. This is also what lets two versions of an application run side by side during a zero-downtime deployment, because a cached page can still find the asset it was built against.
Stale is not always wrong
The two extensions from RFC 5861 are among the cheapest wins available. stale-while-revalidate lets a cache hand a stale copy to the user immediately and refresh it in the background, so nobody waits for the origin. A page served with Cache-Control: max-age=60, stale-while-revalidate=600 refreshes every minute, yet no visitor ever pays the regeneration cost.
stale-if-error keeps serving the stale copy when the origin returns 500, 502, 503 or 504. During a short deployment hiccup, that one directive is often the difference between a site that stays up and a blank page.
There is also the question of giving the browser and the CDN different lifetimes. You cannot purge a browser cache, you can purge a CDN, so the sensible setup is a long edge TTL and a short browser TTL. RFC 9213, also published in June 2022, defines the CDN-Cache-Control field for this: the CDN reads that header, the browser reads the ordinary Cache-Control. A page can then sit at the edge for hours and still be updated worldwide with a single purge call.
The real hazard in application caches: the stampede
The usual pattern is read-through: look in the cache, and on a miss compute the value and store it. The problem arrives the instant a key expires. The hundred requests in flight at that moment all miss, all run the same expensive query, and the database falls over. Redis documentation calls this the thundering herd, application cache libraries call it a dogpile, CDN documentation calls it a cache stampede. Same thing.
Four countermeasures cover it. Coalesce requests so only one computation runs per key and the rest wait on its result. Add random jitter to TTLs, so a thousand keys written at the same moment do not all die at the same moment. Refresh in the background before expiry rather than after. And cache negative results for a short time, otherwise bot traffic asking for records that do not exist lands directly on your database. One caveat worth stating: caching the result of a slow query does not fix the slow query, it postpones it. The index and query plan work still has to happen.
Browsers no longer share their cache
A tactic that worked for a decade no longer does: "load jQuery from a public CDN, the user probably has it cached already." Chrome partitioned its HTTP cache in Chrome 86, in October 2020. Resources are now keyed not only by URL but by the top-level site making the request, so a font downloaded on site A is invisible on site B and gets fetched again. The motivation was privacy and cross-site leak attacks. Chrome's own experiments measured the cost at roughly 4% more network usage and about 0.3% on first and largest contentful paint. Firefox and Safari partition as well.
The practical consequence is that the caching argument for third-party asset hosting is gone. Serving fonts and libraries from your own domain is now both faster and one fewer dependency, and the difference is measurable in your page speed metrics.
When the cache gives the right answer to the wrong person
The security side of caching gets less attention than the performance side and costs more when it goes wrong. A personalised page that lands in a shared cache will be handed to the next visitor along with someone else's data. A response cached while carrying Set-Cookie hands out the session cookie too. This is why private, no-store on authenticated routes should be the default rather than the exception.
There is a subtler class of problem. If the cache and the origin parse the same URL differently, an attacker can get a dynamic page stored as though it were static. This is web cache deception. The "Gotta cache 'em all" research published by PortSwigger Research on 8 August 2024 mapped these discrepancies systematically and found parser differences across many providers, including Cloudflare, Akamai, CloudFront, Azure, Imperva, Google Cloud and Fastly. The examples are concrete: Spring treats a semicolon as a matrix variable separator, Rails reads a dot as a format separator, and some servers handle newline or null bytes differently again. A path like /profile;file.css can look like a profile page to the origin and a stylesheet to the cache.
The defences are unglamorous. Send no-store, private on every dynamic or personalised response. Do not let CDN rules override the origin's Cache-Control, and be careful with broad "cache everything" rules in particular. Turn on your provider's cache deception protection. Most of all, test it once: while logged in, request /account/orders/x.css and then open the same URL in a browser with no session. If you can see your own orders, the vulnerability is already there. The endpoint-level relatives of this problem are covered in API security and the OWASP API Security Top 10.
A first step that fits in a week
List your 20 highest-traffic URLs and record, for each one, the Cache-Control, Age, ETag and CDN status headers from curl -sI. You will probably find three things: routes where you never set a header at all, authenticated responses that are not marked private, and content-hashed static assets cached for a day instead of a year. Fixing all three is a day of work. After that, add stale-while-revalidate to two or three read-heavy endpoints and measure the change in requests reaching your origin; if you already have dashboards and SLOs, the difference shows up the same day. Then put cache tagging in place before you need it, so that when the first urgent correction lands, "purge everything" is not the only button you have.
Need help with this topic?