HTTP Caching Headers Will Ruin Your Week Unless You Actually Understand Them
The worst production incident I've caused personally, not one I inherited or helped clean up but genuinely caused, was an HTTP caching bug. We shipped a fix for a pricing display bug, deployed it, verified it in a fresh incognito tab, called it done — and then spent the next four hours fielding support tickets from customers who were still seeing the old, wrong price. The fix was live. The CDN just hadn't been told the old response was invalid, because we'd set Cache-Control: public, max-age=86400 on that endpoint eight months earlier and completely forgotten about it. It took me longer to find that header than it took to write the original fix. That's the thing about caching bugs — they're invisible until they're a fire, and by the time they're a fire, half the debugging is just remembering caching exists at all.
Cache-Control: the header that does all the actual work
Cache-Control is the header that determines almost everything about whether and how a response gets cached, by both browsers and any CDN or proxy sitting in between. The directives that matter most day to day:
Cache-Control: public, max-age=3600, s-maxage=86400
max-age is how long, in seconds, a browser can treat the response as fresh without revalidating. s-maxage overrides max-age specifically for shared caches — your CDN, a corporate proxy — which is genuinely useful when you want your CDN to hold something for a day but individual browsers to recheck more often, or vice versa. public means any cache, shared or private, can store the response; private means only the end user's browser can cache it, which you want for anything containing user-specific data even if it's not strictly sensitive, because a shared cache serving user A's response to user B is exactly the kind of bug that ends up in a security postmortem.
For anything that changes per-request and should never be cached, don't just set max-age=0 — use Cache-Control: no-store. no-cache is the confusing one: despite the name, it doesn't mean "don't cache," it means "cache it, but revalidate with the server before using it every single time." That naming decision has cost more engineer-hours of confusion than almost any other header in HTTP, in my estimation.
Cache-Control: no-store -- never cache, anywhere, full stop
Cache-Control: no-cache -- cache it, but always revalidate first
Cache-Control: private, max-age=0, must-revalidate -- common for authenticated API responses
ETags and conditional requests
An ETag is a fingerprint of a resource's content, usually a hash. The point of it is to let a client say "I have a cached copy with ETag X, is it still current?" without transferring the whole body if the answer is yes.
Initial response:
HTTP/1.1 200 OK
ETag: "5d8c72a5edda8"
Cache-Control: no-cache
Next request from the same client:
GET /api/products/42
If-None-Match: "5d8c72a5edda8"
Server response if unchanged:
HTTP/1.1 304 Not Modified
A 304 has no body — it just tells the browser "your cached copy is still good, keep using it" — which saves bandwidth even on a resource you've marked no-cache and want revalidated on every load. This is genuinely underused. I've seen APIs that respond no-store to avoid stale data, when what they actually wanted was no-cache plus a properly implemented ETag, which gets you correctness and saves the client from re-downloading a multi-kilobyte JSON payload that hasn't actually changed.
Implementing ETags server-side is usually just hashing the response body or, better, using a version column or updated_at timestamp you already have:
import { createHash } from 'crypto';
app.get('/api/products/:id', async (req, res) => {
const product = await db.getProduct(req.params.id);
const etag = "${createHash('sha1').update(JSON.stringify(product)).digest('hex')}";
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'private, no-cache');
res.json(product);
});
stale-while-revalidate: the directive that actually feels magic
stale-while-revalidate is the one that changed how I think about caching entirely once I actually used it in production. It tells a cache: serve the stale response immediately if it's expired, but kick off a background revalidation request at the same time, so the next request gets a fresh copy.
Cache-Control: max-age=60, stale-while-revalidate=3600
Here, for the first 60 seconds the response is fresh and served directly. For the following hour, requests get the stale response instantly (fast!) while a revalidation happens in the background, invisible to the user who made that request. After the full window, it's a real cache miss and someone eats the full latency. This is what makes it possible to have both very fast responses and reasonably fresh data without the classic tradeoff — it's the mechanism behind a lot of what makes modern CDN-backed static sites and ISR (Incremental Static Regeneration) in Next.js feel instant while still updating regularly. Vercel's edge network and Cloudflare both support it natively, and if you're not using it on content that changes occasionally but doesn't need to be real-time — a blog, a product catalog, a docs site — you're leaving free performance on the table.
How CDNs actually behave, which is not always what the spec says
This is where theory and practice diverge most sharply. CDNs don't uniformly respect every caching header the same way, and this is the source of most "but the header says it shouldn't be cached" incidents. Cloudflare, for instance, has its own cache rules layer that can override origin Cache-Control headers entirely depending on your plan and page rules — I've debugged more than one incident that turned out to be a Cloudflare "Cache Everything" page rule silently ignoring no-store from origin because someone had set up an aggressive caching rule for a different path pattern that happened to match. Always check your CDN's dashboard-level overrides before assuming the origin header is the final word.
Also worth knowing: many CDNs cache based on the full URL including query strings by default, which means /api/products?id=42 and /api/products?id=42&utm_source=twitter are two entirely different cache entries even though they should return identical content. This is a real source of both cache bloat and cache misses — I've seen origin servers get hammered because marketing added UTM parameters to every link, and suddenly every "cached" page was a fresh miss because of the extra query string.
Debugging a caching bug in production
When something's stale and shouldn't be, or fresh when it should be cached, here's the actual sequence I run through:
curl -sI https://example.com/api/products/42
Look at Cache-Control, Age (how long, in seconds, this response has been sitting in a cache — huge tell), and any CDN-specific header like CF-Cache-Status (Cloudflare) or X-Cache (Fastly, CloudFront, Akamai all have their own variants — HIT, MISS, STALE, EXPIRED). If CF-Cache-Status: HIT and Age: 14000 on something you just deployed a fix for, you've found your bug immediately, no further investigation needed.
From there, in Chrome DevTools, the Network tab's "Disable cache" checkbox only affects the browser cache — it does nothing to a CDN edge cache, which is the trap that got me on that pricing incident. To actually rule out CDN caching, hit the origin directly if you can, or add a cache-busting query param and compare.
For actually clearing a bad cache entry, most CDNs support purging by URL or by cache tag/surrogate key if you've set those up — and if you're running anything at real scale, set up surrogate keys (Surrogate-Key header, supported by Fastly and several others) from day one, because purging by exact URL doesn't scale once you need to invalidate "every page that includes this product" after an inventory update. Tag your responses on the way out, and purging becomes a single API call instead of a script that enumerates every affected URL.
The underlying lesson from my pricing incident, and from every caching bug since, is the same: caching headers are a contract you write once and then forget about, but the cache itself doesn't forget — it just sits there, quietly serving something stale, until a human notices and has to go dig through headers to figure out why "the fix that's definitely deployed" isn't showing up for anyone.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.