Skip to main content
Caching Strategies

Why Your Cache Isn't Hitting 95% and What to Do About It

Stop chasing cache hit rates. Learn why a 95% CDN hit ratio may be hiding a problem, and how a layered caching strategy with proper invalidation can actually reduce origin load.

You see 95% in your CDN dashboard and feel good. But here's the truth: a high cache hit rate can mask a badly designed cache, and chasing it can make your site slower. The real metric is origin load—if your cache is absorbing 70% or more of origin requests, you're doing fine (AWS). But a CDN hit ratio that high often means you're caching the wrong things, or you've set TTLs so long that stale content is baked in. So what's the actual question you should be asking? Not "how do I get a higher hit rate?" but "how do I make sure my cache serves the right content, fast, without ever serving stale data to a user who just changed something?"

The Real Problem: Invalidation, Not Hit Rate

You can have a 95% hit rate and still have a terrible user experience. Imagine a user updates their profile, then hits refresh and sees the old data because your CDN is still serving the cached version. That's a correctness bug, not a performance win. The fix isn't to purge your entire cache every time something changes—that defeats the purpose. The fix is to design your cache keys and invalidation strategy so that only the affected resources are removed. Cloudflare lets you purge by single-file, which instantly removes just that one asset from every data center (Cloudflare). That's the surgical approach. But you need to know which files to purge, which means you need a cache key that accurately reflects the content's variance.

What Actually Determines a Cache Hit?

A cache hit happens when the cache key for an incoming request matches a stored response. The key is built from the method, the URL, and—crucially—the headers listed in the Vary response header (MDN). If your origin sends Vary: Accept-Encoding, the cache will store a separate copy for gzip and brotli. That's fine. But if you forget to vary on a header that affects content—like Cookie for a personalized page—you'll either serve the same generic page to everyone or, worse, serve a personalized page to the wrong user. So the first step is to audit your Vary headers. Only include headers that actually change the representation. Over-varying fragments your cache and kills your hit rate.

Freshness vs. Staleness: The Trade-Off

Once you've got your keys right, you have to decide how long to keep content fresh. HTTP caching gives you explicit controls: max-age tells any cache (browser, CDN, nginx) how many seconds to keep the response fresh after it's generated on the origin (MDN). But if you set max-age=604800 (one week) for an HTML page that changes daily, you're serving stale content for up to a week. That's where stale-while-revalidate comes to the rescue. Defined in RFC 5861, it lets a cache serve a stale response for up to a specified number of seconds while it revalidates in the background (RFC 5861). So you can set Cache-Control: max-age=60, stale-while-revalidate=3600 and get the best of both: instant responses for up to an hour, with the origin getting a background refresh every minute. For a news article that's updated occasionally, that's a win. But for a user's profile page, even a minute of staleness is too much. That's when you need no-cache, which allows storage but forces revalidation with the origin before every reuse (MDN). It's not a performance killer—the revalidation uses ETags or Last-Modified headers to return a 304 Not Modified, which is tiny compared to the full body.

Layered Caching: Where to Cache What

Don't put all your eggs in one CDN. A typical architecture has layers: browser cache, CDN edge, load balancer/proxy cache, then your application or distributed cache like Redis or Memcached, and finally the database (AWS). Each layer has its own strengths. The browser cache is fastest for the user, but you can't invalidate it from the server—you have to wait for the TTL to expire. So use immutable for static assets with fingerprinted URLs, like style.abc123.css. The CDN is your workhorse for static content—HTML, images, JS—that's identical for every user (Cloudflare). For dynamic content that's personalized, you need a cache closer to the application. That's where Redis shines. Redis is an in-memory cache with typical hit rates of 90–95% and 1–5 ms latency, and it can reduce database CPU by 70–90% (AWS). But Redis is not a CDN. It's a distributed cache that lives in your data center or VPC.

Invalidating the Right Layer at the Right Time

Here's the thing: you can't rely on TTLs alone for dynamic data. If a user changes their profile, you need that change to be visible immediately. In Redis, you can delete the key right after a write. In a CDN, you can purge by single-file. But what if you have multiple layers? A common pattern is to use a versioned URL for static assets, so you never need to invalidate—just change the version. For dynamic pages, you might use stale-while-revalidate to keep the CDN fresh, and then when a write happens, you purge that exact URL from the CDN and delete the Redis key. That's a two-step invalidation. It sounds simple, but it's easy to miss one layer. The result: you update Redis, but the CDN still has the old page. You purge the CDN, but the browser has a cached copy with max-age=3600. To avoid that, you need to be deliberate about which layer is authoritative for which content.

Putting It All Together: A Concrete Example

Let's say you run a blog with articles that are rarely updated but a sidebar that shows the latest comments. For the article body, you can set Cache-Control: public, max-age=3600, stale-while-revalidate=86400. That way, the CDN will serve the article for an hour, and even after that, it can serve a stale copy for up to a day while it revalidates in the background (RFC 5861). For the sidebar, you don't cache it at all—or you use no-cache so the browser always revalidates. But revalidating every request could hammer your origin. So you put the sidebar in Redis with a short TTL, say 30 seconds, and update it when a new comment is posted. When a comment is posted, you also purge the article URL from the CDN, so the next request gets a fresh version. That's a layered strategy that balances freshness and performance.

Bottom Line

Stop obsessing over hit rates. Instead, design your cache with invalidation in mind. Use Vary to keep keys accurate, use stale-while-revalidate to smooth out origin spikes, and purge surgically when data changes. The single best move you can make today is to add a Cache-Control header that includes stale-while-revalidate to your dynamic HTML responses—it's a low-effort change that can cut origin load dramatically without sacrificing freshness.

Sources

  • AWS - https://aws.amazon.com/caching/
  • Cloudflare - https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/
  • MDN - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
  • MDN - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
  • RFC 5861 - https://www.rfc-editor.org/rfc/rfc5861.txt

Share this article:

Comments (0)

No comments yet. Be the first to comment!