Back in 2019, I watched a single misconfigured `Vary` header tank an entire edge PoP to a 62% hit rate during a product launch. The dashboards still showed green because the global average was 91%, but origin was melting. That’s the thing about cache misses — they don’t fail loudly, they just quietly send your bill and latency through the roof until you know how to look for them.
The 95% Hit Rate Lie: Where Expensive Misses Hide
Early in my time running CDN caching for a large media platform, I learned to stop trusting the global hit ratio. A 95% hit rate sounds great in a quarterly review, but if that remaining 5% is all cache misses for your heaviest, uncacheable API responses or large video segments, your origin costs and p95 latency will tell a very different story. I’ve seen setups where we had a 96% request hit rate but only a 71% byte hit rate — meaning we were serving lots of tiny cached hits while every large object was punching through to origin. That’s not a win, that’s a filtering problem.
The first step in any serious miss rate analysis isn’t looking at the percentage itself, it’s segmenting it. You can’t fix what you can’t slice. I always break misses down by content type, by PoP, by cache status, and by URL pattern before I even think about tuning. Are your misses concentrated on HTML documents with `Cache-Control: private`? Are they all `MISS` on image thumbnails with query-string busting like `?v=12345`? Or are you seeing a ton of `EXPIRED` or `STALE` revalidations that should’ve been hits? Each pattern points to a completely different fix, and treating them all as a generic "miss" is how teams waste months tweaking TTLs that were never the problem.
We once had a client whose hit rate improvement stalled at 88% for weeks. Turns out 40% of their misses were actually `BYPASS`es caused by cookies. Their origin was setting `Set-Cookie` on every static asset response for A/B tracking, and our default VCL was correctly refusing to cache anything with that header. No amount of cache optimization on the TTL side would’ve helped. The fix was stripping that cookie at the edge for static paths — a one-line change that bought them 9 points of hit rate overnight. If you’re not logging your `X-Cache` or `Cache-Status` header values distinctly, you’re flying blind.
# Parse CDN logs and segment miss reasons - don't just count MISS
# Expects combined log with cache_status as last field: HIT, MISS, BYPASS, EXPIRED, STALE
awk '{status=$NF; count[status]++} END {for (s in count) print s, count[s]}' /var/log/cdn/access.log | sort
# Python snippet for deeper miss rate analysis by content type and path pattern
import collections
from urllib.parse import urlparse
miss_by_pattern = collections.Counter()
with open('/var/log/cdn/access.log') as f:
for line in f:
parts = line.split()
url = parts[6]
status = parts[-1] # HIT/MISS/BYPASS
if status != 'HIT':
# Group by extension and first path segment
path = urlparse(url).path
ext = path.split('.')[-1] if '.' in path else 'no-ext'
bucket = f"{ext}:{path.split('/')[1] if len(path.split('/'))>1 else '/'}"
miss_by_pattern[bucket] += 1
for pattern, count in miss_by_pattern.most_common(15):
print(f"{pattern:20} {count}")
Anatomy of a Cache Miss: Cold, Capacity, and Self-Inflicted
Not all cache misses are created equal, and I’ve found the classic CPU cache taxonomy still maps surprisingly well to CDN work. You’ve got cold misses (first time that object’s been requested at that edge), capacity misses (the object was evicted because your cache is full), and what I call self-inflicted misses — where your own configuration prevents a hit. That last category is where I spend 80% of my debugging time. Things like over-specific cache keys, `Vary: User-Agent` on static assets, or low TTLs set out of caution will create misses that look like traffic patterns but are really just config debt.
Cache key design is the biggest culprit I see for self-inflicted misses. I audited a site last year that included the entire query string in its cache key by default. Every Facebook `fbclid` parameter, every `utm_source` tag was creating a unique cache entry for the exact same page. Their miss rate was 34% on what should’ve been highly cacheable HTML. We normalized the key to only include whitelisted params like `page` and `lang`, and the hit rate for that path jumped from 66% to 93% in an hour. If you’re doing hit rate improvement work and you haven’t looked at your key normalization rules, start there. It’s also worth checking how Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups can help you avoid caching one-hit wonders that just churn your capacity.
For cold misses, the question is whether they’re unavoidable or a sign you need warming. A new PoP will always be cold, but if you’re seeing high cold miss rates on steady-state PoPs for popular objects, you likely have too many edge nodes for your traffic volume or your TTLs are too short to keep objects warm. That’s where origin shielding and tiered caching come in. And if your misses show a pattern of `EXPIRED` hits that revalidate every few seconds, you’ll want to look at stale-while-revalidate and stale-if-error patterns described in the HTTP Working Group Specifications — they let you serve stale content while you refresh in the background instead of blocking on origin. I also recommend reading the guidance on web.dev Articles for setting sensible `Cache-Control` directives for different asset types before you invent your own.
// Inspect actual cache behavior in VCL / edge logic
// Log the full cache key and miss reason for non-hits
sub vcl_miss {
std.log("miss: key=" + req.hash + " url=" + req.url + " reason=cold_or_expired");
}
sub vcl_hit {
if (obj.ttl <= 0s) {
std.log("miss: key=" + req.hash + " reason=expired ttl=" + obj.ttl);
}
}
// Check your current Cache-Control and Vary headers live
curl -s -D - -o /dev/null https://www.example.com/static/app.js | grep -i -E 'cache-control|vary|cache-status|x-cache|age'
# Normalize cache keys by stripping marketing params (example nginx proxy_cache_key)
# Instead of $scheme$proxy_host$request_uri (includes all params)
# Use only whitelisted args
proxy_cache_key "$scheme$proxy_host$uri$is_args$arg_page$arg_lang";
Metrics That Actually Diagnose Miss Problems
I’ve stopped reporting a single “cache hit rate” to engineering teams because it hides more than it reveals. What you need is a small set of complementary metrics that tell you *why* you’re missing. Request hit rate is your headline, but byte hit rate tells you if you’re offloading origin bandwidth, and origin offload percentage tells you if your shield is working. I also track hit rate by status code — a 20% miss rate on 200s is very different from a 90% miss rate on 302 redirects you never intended to cache anyway.
One metric that’s saved me countless times is the “hit rate for cacheable requests only.” Filter out the uncacheable traffic — `POST`s, `Authorization` headers, `Cache-Control: no-store` responses — and then calculate your hit rate. If your overall hit rate is 82% but your cacheable hit rate is 98%, you don’t have a caching problem, you have a cacheability problem. Your focus should shift from cache optimization to making more responses cacheable in the first place: removing unnecessary `Set-Cookie` headers, fixing `private` directives, or splitting dynamic and static content. For distributed setups, you’ll also want to understand how Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync affect your revalidation misses across PoPs.
Don’t forget to correlate miss rate with latency and origin load. A 2% increase in miss rate might seem minor until you see it corresponds to a 300ms jump in p95 latency and a 4x spike in origin CPU. That’s the cost you’re really trying to reduce. When you start planning predictive warming to smooth cold misses, the approach in Machine Learning for Cache Warming: Predictive Prefetching That Actually Works is a solid next step once your basics are solid. Until then, get your measurements right — here are the thresholds I use to decide if we need immediate action or just tuning.
| Metric | How to Calculate | Healthy Range (Intermediate CDN) | Needs Investigation |
|---|---|---|---|
| Edge Request Hit Rate | HIT / (HIT + MISS + BYPASS + EXPIRED) | 90% - 96% | < 85% or > 5% drop week-over-week |
| Byte Hit Rate | Cached Bytes Served / Total Bytes Served | 85% - 92% | < 75% (large objects bypassing cache) |
| Cacheable Request Hit Rate | HIT / (HIT + MISS + EXPIRED) [excluding BYPASS] | > 95% | < 90% (key/TTL/eviction issue) |
| Origin Shield Hit Rate | Shield HIT / Shield Requests | > 90% | < 80% (shield misconfigured or TTL too low) |
| Stale / Revalidated Ratio | STALE + 304 Revalidated / Total Requests | 2% - 8% | > 15% (TTL too short or thundering herd) |
Implementing Systematic Miss Reduction: From Diagnosis to Deployment
You've got the metrics — now you've got to fix the misses without breaking freshness or blowing up origin costs. I don't start with TTLs. I start with key normalization, because it's the cheapest win you'll ever get. On a recent media property we audited, 38% of all capacity misses were just duplicate keys: `?utm_source=`, `?fbclid=`, and case variations on product slugs. The origin was rendering the same 2.4MB JSON payload 14 different ways. Normalizing the cache key at the edge dropped origin fetch volume by 41% overnight with zero TTL changes.
The second pass is always TTL stratification. Don't use a single `max-age=300` and call it a day. Your content isn't equally volatile. We typically split into three tiers: static assets (images, fonts, JS/CSS) at 1 day to 30 days with immutable hashing, semi-dynamic pages (category listings, article bodies) at 5-15 minutes with stale-while-revalidate, and truly dynamic fragments (cart, price, inventory) as private or `max-age=0` with edge ESI. When we moved a commerce customer from a flat 60s TTL to this model — 86400s for hashed assets, 900s for PDPs, 120s for PLPs — their edge hit rate went from 89.2% to 96.7% and origin p95 dropped from 1.4s to 210ms. If you're running a multi-tier POP architecture, pair this with origin shielding so a regional miss doesn't become a global origin stampede.
Finally, get your hash distribution right before you scale out. We see teams add 20% more cache nodes and wonder why hit rate flatlines. If you're using naive modulo hashing, you're reshuffling ~93% of keys on every node change. That's a self-inflicted capacity cliff. This is where Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide pays for itself. Proper consistent hashing with bounded loads keeps remapping under 8-12% and keeps your hit rate stable during autoscaling events. Here’s the VCL pattern we use at the edge to handle normalization, shielding, and tagged TTLs in one place:
sub vcl_recv {
# 1. Normalize key: strip marketing params & lowercase
set req.url = regsuball(req.url, "[\?&](utm_[^&]+|fbclid|gclid)=[^&]*", "");
set req.url = regsub(req.url, "\?&", "?");
set req.url = regsub(req.url, "\?$", "");
# 2. Collapse key for A/B variants - don't fragment cache
if (req.http.Cookie ~ "ab_test=") {
set req.http.X-Cache-Variant = "ab-v2";
}
# 3. Shielding + thundering herd protection
if (req.backend.is_shield) {
set req.grace = 24h;
}
}
sub vcl_backend_response {
# 4. Stratified TTLs + Surrogate Keys for later purge
if (bereq.url ~ "^/static/") {
set beresp.ttl = 30d;
set beresp.http.Surrogate-Key = "static asset-" + regsub(bereq.url, "^/static/([^/]+)/.*", "\1");
} else if (bereq.url ~ "^/api/product/") {
set beresp.ttl = 15m;
set beresp.http.Surrogate-Key = "product-" + beresp.http.X-Product-ID + " category-" + beresp.http.X-Category-ID;
set beresp.grace = 1h;
set beresp.http.Stale-While-Revalidate = "60";
}
# Don't cache Set-Cookie responses
if (beresp.http.Set-Cookie) {
set beresp.ttl = 0s;
set beresp.uncacheable = true;
return (deliver);
}
}
Advanced Troubleshooting: When Your Hit Rate Plateaus at 92%
If you've done normalization and TTL stratification and you're still stuck in the low-90s, you're almost certainly fighting fragmentation or stampedes. The classic culprit is `Vary` abuse. We debugged a site that varied on `User-Agent` with no normalization — 18,000 distinct UA strings created 18,000 copies of the homepage in cache. Hit rate looked okay in aggregate, but cache efficiency was 11%. You'll want to normalize `Vary` down to what you actually need: usually just `Accept-Encoding` and maybe `Accept-Language` bucketed into 5-6 locales, not 200. Anything more granular — device class, logged-in state — should be handled via separate keys or edge ESI, not `Vary`.
Thundering herds are the other silent killer. When a popular key with a 60s TTL expires, we’ve seen 400-800 concurrent requests hit origin in the same second. Your origin p99 latency spikes to 4-6s, the load balancer sheds, and suddenly your miss rate *causes* more misses. Fix this with three controls together: `stale-while-revalidate` for 60-90% of the next TTL window, `stale-if-error` for resilience, and request coalescing (also called collapsed forwarding) at the shield POP. Fastly and Cloudflare do this automatically if you enable it — Varnish needs `vmod_request` or `shield` config. Don't rely on `grace` alone without coalescing; you'll just serve stale while still hammering origin.
The last plateau-breaker is fixing your invalidation strategy so you can actually *use* longer TTLs. Teams keep TTLs short because they're scared to purge. URL-based purging doesn't scale — purging 12,000 product URLs after a single price feed update takes 45+ seconds and still misses edge variants. That's why you need a tag model from day one. A well-designed Cache Tag Design Patterns: Organizing Content for Granular Invalidation lets you tag a response with `product-98421`, `category-footwear`, and `brand-nike` simultaneously, and then purge by tag instead of URL. For implementation, we lean heavily on Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation to keep purge payloads under 256 bytes and invalidate thousands of variants in <150ms globally. We've measured this move alone letting teams increase average TTL by 6-10x without sacrificing freshness — that's where you go from 93% to 98.5% hit rate.
Frequently Asked Questions
Our dashboard shows a 95% hit rate but origin costs are still brutal. What are we missing?
That's the 95% lie. Your hit rate is likely averaged across everything, including long-tail static assets that naturally hit at 99.8%. Check hit rate weighted by origin cost or bytes served, not request count. We often find that the top 5% of misses — usually uncacheable API calls, fragmented keys, or PDPs with short TTLs — account for 70-80% of origin egress and compute. Segment by content-type, cache status (HIT vs. MISS vs. PASS), and POP. If your MISS p95 TTFB is 1.8s and your HIT p95 is 42ms, those misses are dominating user-perceived latency even if they're only 5% of requests.
What's a realistic target hit rate for a dynamic e-commerce or media site?
Don't chase 99.9% if you have personalized content. For anonymous traffic, you should target 96-98.5% at the edge and 92-95% for shield/origin. For mixed anonymous + logged-in, aim for 90-94% edge with smart ESI — cache the anonymous shell at 95%+ and hole-punch only the cart/user bar as private. If you're below 88% after key normalization, you have a structural problem: bad Vary, no shielding, or TTLs under 60s. If you're above 98% but your stale rate is >4%, you’ve probably set TTLs too long without proper purging.
Should we cache responses that contain a Set-Cookie header?
Almost never. If you cache a response with `Set-Cookie`, you'll replay one user's session cookie to the next 10,000 visitors — that's a security incident. Set `beresp.uncacheable = true` or `Cache-Control: private, no-store` whenever origin sends Set-Cookie. The exception is a deliberate edge-set cookie for A/B or geo — but that should be set in VCL/Worker, not cached from origin. Also watch for `Vary: Cookie` — it fragments your cache just as badly. Strip cookies in `vcl_recv` for cacheable paths and only forward them for `/api/cart` or `/checkout`.
When should we use Surrogate Keys instead of just increasing TTL and waiting?
Use Surrogate Keys any time content is shared across many URLs and freshness matters in minutes, not hours. Example: a single product price appears on the PDP, 8 category pages, search results, homepage merch, and related-products ESI. With TTL-only, you'd need a 60s TTL to stay fresh. With Surrogate Keys, you set a 15-minute or 1-hour TTL and purge `product-12345` instantly when the price changes — one API call invalidates 200+ cached variants in <200ms. If your purge rate is >10 purges/minute or you purge more than 50 URLs per event, you're overdue for keys.