Cache-Control headers are one of those things everyone copies from a Stack Overflow answer without fully understanding, right up until a caching bug ships something genuinely alarming to production. This post reproduces four real Cache-Control mistakes on purpose, with the actual curl commands showing what a browser and a CDN each do differently with each header combination — including the one that briefly served a cached, logged-in account page to a different, logged-out visitor.
Mistake 1: no Cache-Control header at all
curl -I https://example.com/api/products/42
HTTP/1.1 200 OK
Content-Type: application/json
# no Cache-Control header present at all
Without an explicit header, caching behavior is left to each browser and CDN's own heuristics, which differ enough between them to be genuinely unpredictable — some browsers apply a heuristic freshness lifetime based on the response's Last-Modified date, some CDNs default to not caching at all, and neither behavior is something you actually chose. This is the most common real-world mistake, not because anyone decided "let's leave this undefined," but because nobody set it at all, and the absence of a decision became an accidental one.
Mistake 2: caching something that's actually user-specific
This is the one that caused a real, uncomfortable incident. An account settings endpoint had a blanket caching middleware applied site-wide, intended for public marketing pages, that also caught this authenticated route:
Cache-Control: public, max-age=3600
A CDN in front of the app cached the first response to /account/settings — including that specific logged-in user's name and email, rendered into the HTML — and served that exact cached page to the next visitor who hit the same URL, regardless of who they actually were or whether they were logged in at all, for up to an hour. The public directive explicitly tells any cache, including a shared CDN, that the response is fine to store and reuse for any and every requester — which is exactly wrong for a response containing one specific person's private data.
# The actual fix
Cache-Control: private, no-store
private restricts caching to the requesting user's own browser cache, never a shared CDN or proxy, and no-store goes further, forbidding storage anywhere at all, including the browser — the correct choice for anything containing sensitive per-user data that should never persist in any cache layer, shared or private. The incident here wasn't caused by a caching library bug; it was caused by an overly broad middleware applying one caching policy to routes with fundamentally different privacy requirements, which is worth checking for directly: audit which of your Cache-Control headers are set globally versus per-route, and confirm nothing user-specific is caught in a broad "cache everything" net.
Mistake 3: max-age without understanding what it actually promises
Cache-Control: public, max-age=86400
This tells any cache the response is fresh for 86400 seconds (24 hours) from the moment it was fetched — not from when it was deployed, not from when it was last actually changed, from the moment each individual cache fetched it. Deploying a fix to a public product page with this header doesn't make visitors with an already-cached copy see the fix; they keep seeing the stale version until their individual cached copy's 24-hour window expires, which could be anywhere from a few seconds to nearly a full day after the fix shipped, entirely dependent on when they happened to first load that specific page.
For content that changes and needs to be reflected promptly after deploy, either a much shorter max-age, a cache-busting strategy (versioned URLs, changing a query string on deploy), or an explicit CDN purge as part of the deploy pipeline is needed — max-age alone, on its own, guarantees nothing about when a change actually becomes visible to a given cached visitor.
Mistake 4: stale-while-revalidate, misunderstood as "always fresh"
Cache-Control: public, max-age=60, stale-while-revalidate=3600
This one is genuinely useful and genuinely misunderstood. It means: serve the cached version immediately for up to 60 seconds without any check at all, and for up to an additional 3600 seconds beyond that, still serve the (now technically stale) cached version immediately while triggering a background revalidation request the visitor never has to wait on. The value proposition is real — visitors basically never wait on a cache miss during that stale window, since they always get an instant cached response while the actual refresh happens invisibly in the background.
The misunderstanding: this does not mean visitors always see current data within a minute. Within the stale-while-revalidate window, a visitor can genuinely be shown data that's up to 3600 seconds (an hour) old, because the trigger for revalidation is "someone made a request during the stale window," not a fixed background schedule independent of traffic. For a low-traffic page, the actual staleness a real visitor experiences could be considerably longer than the max-age alone suggests, since nothing revalidates until a request actually arrives to trigger it.
Where CDN configuration and origin headers can quietly disagree
A detail that caused real confusion during the incident investigation: the origin server's Cache-Control header and the CDN's own dashboard-configured caching rules were two separate, independently-editable settings, and they didn't necessarily agree with each other. A CDN can be configured to override, ignore, or extend the origin's Cache-Control header entirely through its own rules engine — which means "what does the response header say" and "what will the CDN actually do" are two genuinely different questions that both need answering, not one. Auditing the incident required checking both the origin's actual emitted header (via a direct curl to the origin, bypassing the CDN) and the CDN's own cache-rule configuration for that specific route pattern, because either one alone gave an incomplete picture of what a real visitor was actually going to receive.
# Bypass the CDN entirely to see what the origin itself sends:
curl -I -H "Host: example.com" https://origin-direct.example.com/account/settings
# Compare against what actually reaches a real visitor through the CDN:
curl -I https://example.com/account/settings
A mismatch between these two responses is a strong signal that the CDN's own rules are overriding or supplementing the origin's stated policy — worth knowing before assuming a header change in application code alone is sufficient to fix a caching bug that might actually live partly in CDN configuration instead. Fixing the application code and declaring victory without checking the CDN side separately is exactly how a caching bug can appear "fixed" in a direct origin check while a real visitor, going through the CDN, keeps seeing the old broken behavior for a confusing length of time afterward.
Testing all four with the same curl commands, to see the difference directly
# Check what's actually being sent, for any URL:
curl -I https://example.com/some-page
# Force-bypass your own browser cache to see a genuinely fresh
# response, useful for confirming a fix actually deployed:
curl -I -H "Cache-Control: no-cache" https://example.com/some-page
# Check response age from a CDN specifically, when present:
curl -I https://example.com/some-page | grep -i "age:"
The Age header, when a CDN is involved, reports how many seconds ago the CDN itself fetched the response from the origin — genuinely useful for confirming whether you're looking at a fresh origin response or a cached edge copy, and for how long that edge copy has actually been sitting there relative to a suspiciously stale-looking page. A large, unexpected Age value on a page that should have been recently updated is often the fastest single signal that a cache, somewhere in the chain, didn't actually revalidate when you expected it to.
Caching decisions for compressed static assets specifically
Static images and PDFs deserve a specific mention because they're both a genuinely safe case for aggressive, long max-age caching and a common place where caching policy gets tangled up with a separate concern: file size. Compressing an image through an image compressor before it ever ships shrinks what has to be transferred on a cache miss, while a long max-age combined with a content-hashed filename (so the URL itself changes whenever the file's content does) minimizes how often that transfer has to happen again for repeat visitors at all. The two are complementary, not substitutes for each other — a small file that's fetched on every single page load because of an overly short max-age is still slower in aggregate than a slightly larger file cached aggressively and fetched once.
A decision guide, not a copy-paste default
- Anything user-specific or containing private data:
private, no-store, no exceptions, checked explicitly on every route rather than assumed to be covered by a global default. - Public content that rarely changes (a static marketing page, a compiled CSS/JS bundle with a content hash in its filename): long
max-age, safely, since a content-hashed filename changes on every real update, making staleness a non-issue by construction. - Public content that changes occasionally and needs quick propagation: short
max-ageplusstale-while-revalidate, understanding the real staleness window it implies rather than assuming near-instant freshness. - Anything genuinely uncertain: default toward shorter cache lifetimes and more conservative directives — the cost of a slightly less cache-efficient response is almost always smaller than the cost of a caching bug like the account-page incident above.
If your assets are also large enough that caching them longer genuinely matters for load time, that's a real optimization worth pursuing — see what actually moved our Core Web Vitals for where asset weight, not just caching policy, made the bigger measured difference. And if you're building any kind of caching layer of your own rather than relying purely on HTTP headers, the same freshness-versus-staleness trade-off shows up again, in a different form, in building a simple in-memory cache with expiry — the underlying trade-off is the same one, just moved from an HTTP header to application code.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.