Skip to main content
Caching Strategies

Stop Guessing Your Cache TTL: A Case for Stale-While-Revalidate

Most teams set a cache TTL and call it done. That's a mistake. Here's why stale-while-revalidate beats blind freshness windows—and how to use it.

What is the best caching strategy for web applications?

I'll give you my answer up front: stop treating cache freshness as a binary and adopt stale-while-revalidate as your default for anything cacheable that isn't user-specific. It's the single highest-leverage change most teams can make, and it's already in the HTTP spec.

Here's why. The classic advice—pick a TTL, set max-age, move on—forces a bad tradeoff. Set the TTL short and you hammer the origin with revalidations. Set it long and users stare at stale data until the clock runs out. The stale-while-revalidate directive, defined in RFC 5861, lets a cache serve a response stale for up to the indicated seconds while revalidating it in the background. That means the user gets an instant response, and the cache refreshes itself for the next request. You get the latency of a long TTL with the correctness of a short one.

I pair this with stale-if-error, also from RFC 5861, which lets a cache return a stale response when the origin throws a 500 or a DNS failure. In practice this is the difference between your site going down and your site looking a little old for a few minutes. For a content site, that's an easy call.

Why plain max-age keeps failing you

The max-age directive keeps a response fresh until N seconds after it was generated on the origin—not after it was received, which is a subtlety that trips people up. So Cache-Control: max-age=604800 is seven days of freshness from generation time. Fine. But the moment that window closes, every request in flight has to revalidate, and if your origin is slow, your users feel it.

People try to patch this with validators. ETags and Last-Modified are genuinely useful—ETag gives you a version identifier so the server can skip resending the full response, and Last-Modified serves as a fallback when ETags aren't available. But validators only help when the origin is reachable and responsive. They don't help during a brownout.

There's also a trap in the standard itself. RFC 9111 says that when a response has no explicit freshness, caches may apply heuristic freshness—and with a Last-Modified header a typical heuristic is no more than 10% of the interval since last modification. If your page was last modified a year ago, that's a 36-day heuristic freshness window you never explicitly chose. I've seen teams debug "why isn't my update showing up" for hours before realizing they never set Cache-Control at all.

The counter-argument, and why I'm rejecting it

The strongest objection I hear is: "We can't serve stale data. Our users will see wrong prices, wrong inventory, wrong everything."

That's a real concern, and it's why I'm not saying use stale-while-revalidate everywhere. I'm saying use it everywhere that isn't user-specific or financially transactional. There's a clean dividing line: if a response varies per user, it belongs behind private or no-store, not in a shared cache. If it's the same for everyone, stale-by-30-seconds is almost always acceptable and the latency win is enormous.

For the genuinely sensitive stuff, use must-revalidate. RFC 9111 is explicit: once a response is stale, a cache must not reuse it until it's been validated, and a disconnected cache must return an error—typically a 504—rather than serve the stale copy. That's the right tool for the job. The mistake is treating that as the default for everything.

Where this actually pays off

Consider a product page on a mid-size e-commerce site. The catalog data changes maybe a few times a day. Under a naive max-age=300, you revalidate every five minutes, and every revalidation that hits a cold origin adds latency. Under max-age=300, stale-while-revalidate=86400, the cache serves the five-minute-fresh version instantly, and if the origin is slow or down, it can keep serving for up to a day while it tries to refresh.

This is where CDNs do the heavy lifting. Cloudflare's network spans 348 cities across 8 regions, with 55 in North America alone, and CDN cache hit ratios commonly reach 80% to 95% while absorbing 70% or more of origin requests (AWS caching overview). Tiered Cache pushes this further by dividing data centers into lower and upper tiers, so only the upper tier contacts the origin. That's available on all plan types, including Free, with the default Smart Tiered Cache topology. If you're on a CDN and not using tiered caching, you're leaving origin load on the table for no reason.

On the application side, Redis is the workhorse. Typical hit rates run 90% to 95% with 1 to 5 ms latency, and adding it often cuts database CPU by 70% to 90% (AWS caching overview). The cache-aside pattern—check cache, miss, fetch from DB, write back—is still the right default for most reads. But be honest about eviction. Redis uses LRU by default, and allkeys-lru is the recommended policy because a cache is not storage. If you're running noeviction, you've turned your cache into a fragile database that returns errors when full.

The invalidation problem nobody wants to talk about

Everyone repeats the joke about cache invalidation being hard. The HTTP spec actually handles a lot of it for you. RFC 9111 says unsafe methods like PUT, POST, and DELETE must cause intervening caches to invalidate the stored target URI when they return a non-error status. So if you're doing proper REST semantics, your writes already invalidate your reads.

For everything else, purge explicitly. Cloudflare's purge-by-single-file instantly removes a resource across all data centers, so the next request gets the latest version. That's a much better tool than shortening your TTL globally to compensate for one volatile page.

My recommendation is boring but specific: set a long max-age with stale-while-revalidate and stale-if-error on shared, non-user-specific responses; use must-revalidate for anything that genuinely can't be stale; lean on your CDN's tiered cache; and purge by URL when something changes. Do that and you'll spend less time tuning TTLs and more time on the parts of your system that actually need the attention.

Sources

  • RFC 5861 (stale-while-revalidate) - https://www.rfc-editor.org/rfc/rfc5861.txt
  • RFC 9111 (HTTP Caching) - https://httpwg.org/specs/rfc9111.html
  • MDN (Cache-Control) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
  • AWS (caching overview) - https://aws.amazon.com/caching/
  • Cloudflare (network) - https://www.cloudflare.com/network/
  • Cloudflare (tiered cache) - https://developers.cloudflare.com/cache/how-to/tiered-cache/

Share this article:

Comments (0)

No comments yet. Be the first to comment!