CDN Cache Hit Ratio Optimization: From 60% to 95% Hit Rate

A 60% cache hit ratio feels like your CDN is working. Requests are flowing, origins aren't melting, and dashboards stay green. But when you translate that number to origin cost and user latency, 60% means 40% of your traffic — often your most expensive, uncacheable-looking 40% — is still making the full round trip. In my experience running edge delivery for high-traffic media and e-commerce platforms, moving from 60% to 95% is not about buying more PoPs. It is about systematically eliminating the fragmentation, misconfiguration, and fear-based TTLs that force the cache to miss. This article breaks down the exact levers I have used to close that gap and keep it closed.

Most teams assume a low hit ratio is a traffic composition problem. In reality, it is usually a cache key and policy design problem. When we audited a 60% hit ratio platform serving 4B requests per month, we found 73% of misses were for byte-identical objects that were stored under different keys, expired too early, or bypassed the cache due to a single unnecessary Vary or Cookie header. Fixing those earned us 28 points of hit ratio before we ever touched origin code. The following is the framework we use to replicate that result.

Diagnosing the Anatomy of a 60% Hit Ratio: Miss Taxonomy and Measurement Pitfalls

Before you optimize, you need to measure correctly. The hit ratio number reported by most CDN dashboards is edge hits / (edge hits + edge misses), but that conflates several very different miss types. I've found that separating them into a miss taxonomy is the only way to prioritize work.

Building a True Miss Breakdown

Instrument your edge logs to classify every miss. The categories I use are: Origin Miss (first fetch, genuinely uncached), Expired Revalidation (object was cached but TTL expired), Key Fragmentation Miss (same object, different cache key), Bypass Miss (request intentionally sent to origin due to Cookie/Authorization/Cache-Control: no-cache), and Uncacheable Miss (response with Cache-Control: private, no-store or Set-Cookie). On a typical 60% site, the distribution looks like 10% Origin Miss, 25% Expired Revalidation, 30% Key Fragmentation, 20% Bypass, and 15% Uncacheable. Your biggest ROI is almost always fragmentation and expired revalidation.

Be careful with status code filtering. Many platforms calculate hit ratio only on 200s. If your origin returns a high volume of 404s or 301s that are not cached, they will devastate your origin without ever appearing in the hit ratio. Include 2xx, 3xx, and 4xx in your denominator, and track cache_status (HIT, MISS, REVALIDATED, BYPASS, EXPIRED) as a dimension, not just a metric.

The Origin Concurrency Illusion

A low hit ratio rarely shows up as high average origin latency. It shows up as high P95 origin concurrency and thundering herds during traffic spikes. One client with a 62% hit ratio had an average origin CPU of 35% but spiked to 95% every time a popular product page expired. The TTL was 60 seconds, and 800 edge nodes revalidated simultaneously. The fix was not more origin capacity. It was request coalescing and a longer TTL with active invalidation, which we will cover below. As defined in the HTTP Working Group Specifications for caching, a validated response can be reused efficiently only if you allow it.

Normalizing Cache Keys: Taming Query Strings, Cookies, and Vary Header Fragmentation

If you fix only one thing, fix cache keys. A fragmented key space is the single largest driver of sub-80% hit ratios I see. Two requests for /product/123?utm_source=facebook&utm_medium=cpc and /product/123?utm_medium=cpc&utm_source=facebook are identical, but by default they cache as two objects.

Query String Sorting and Allowlisting

Never use the raw query string as part of the cache key unless the parameter changes the response body. For most sites, only 2-4 query parameters actually matter (e.g., lang, currency, page, variant). Everything else — utm_*, gclid, fbclid, _ga — should be stripped before the cache lookup, not just ignored downstream.

Implement an allowlist, not a blocklist. Blocklists always miss a new marketing parameter. At the edge, normalize the key by stripping unknown params, sorting the remaining ones, and lowercasing keys. This alone lifted one retail site from 68% to 84% in a week.

# Example: Varnish VCL for query string normalization
sub vcl_hash {
    # Only include allowlisted params in cache key
    set req.url = querystring.filter(req.url, "lang,currency,page,sku");
    set req.url = querystring.sort(req.url);
    hash_data(req.url);
    # Device class from Client Hints, not UA string
    if (req.http.Sec-CH-UA-Mobile) {
        hash_data(req.http.Sec-CH-UA-Mobile);
    }
    return (lookup);
}

sub vcl_recv {
    # Strip marketing params before lookup, not after
    set req.url = querystring.remove(req.url, "utm_source,utm_medium,utm_campaign,gclid,fbclid,_ga,_gid");
    # Remove cookies unless route requires them
    if (req.url ~ "^/api/cart|^/account") {
        return(pass);
    }
    unset req.http.Cookie;
}

Cookies and the Vary Header Trap

Cookies are even more destructive than query strings. If your CDN includes Cookie in the cache key by default, every user with a unique _ga or session cookie gets a separate cached object, even for public assets. I've found that the correct pattern is to strip cookies at the edge for all cacheable content types and only forward them for explicit dynamic routes. Similarly, audit your Vary header. Vary: User-Agent is catastrophic — it creates thousands of variants for the same image. Vary: Accept-Encoding is necessary but should be normalized to two variants (gzip, br) at the edge, not passed through raw. Vary: Cookie should almost never be set on a CDN-cached response.

For A/B tests and personalization, do not vary on a cookie value. Instead, normalize the user into a bucket at the edge (e.g., x-experiment: a or x-experiment: b) and include only that bucket header in the cache key. This reduces n variants from millions to 2 or 3.

Architecting TTL Stratification for Static, Semi-Dynamic, and Personalized Payloads

The second major leak is irrational TTL assignment. Teams often set a single conservative TTL like max-age=60, s-maxage=60 for everything because they fear staleness. That guarantees a low hit ratio. A mature setup uses TTL stratification: different lifetimes by content mutability, combined with active purging. For a full breakdown of header semantics, see CDN Caching Strategies: TTL, Cache-Control Headers and Invalidation Patterns.

I stratify content into four tiers. Immutable assets (hashed JS/CSS, images with content hashes) get max-age=31536000, immutable. Static semi-dynamic content (product description, category pages, marketing pages) gets s-maxage=86400 to 7 days plus stale-while-revalidate. API responses and price/inventory get s-maxage=60-300 with stale-while-revalidate=30. Truly personalized responses get private, no-store and should bypass the cache entirely — do not try to cache them publicly.

# Edge origin response headers for stratified TTLs
# Hashed static asset - immutable
Cache-Control: public, max-age=31536000, immutable
CDN-Cache-Control: public, max-age=31536000

# Product page - static but needs invalidation
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=600, stale-if-error=86400
Surrogate-Key: product-123 category-shoes brand-nike
CDN-Cache-Control: public, s-maxage=86400

# Price API - short TTL with background revalidation
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30, stale-if-error=300
Surrogate-Key: price-123
Vary: Accept-Encoding

Notice the use of CDN-Cache-Control and Surrogate-Key. CDN-Cache-Control lets you set a long edge TTL while keeping browser TTL short, which improves hit ratio without risking browser staleness. Surrogate keys, covered in CDN Purge Strategies: Selective Invalidation, Tag-Based and Surrogate Keys, let you keep TTLs long because you can purge precisely when data changes instead of expiring preemptively. A team that moved from 60-second TTLs to 24-hour TTLs with surrogate key purges went from 71% to 91% overnight, with no increase in stale delivery.

Shielding and Tiering: How Request Collapsing and Regional Aggregation Reclaim 15% Hit Rate

Even with perfect keys and TTLs, a large CDN footprint works against you. If you have 250+ edge PoPs and an object expires, all 250 PoPs will miss and request it from origin simultaneously. This is the thundering herd problem. A 60% hit ratio often hides a topology problem, not a policy problem. This is where multi-tier caching earns its keep.

An origin shield — a single mid-tier PoP or region designated as the sole forwarder to origin — collapses those 250 parallel misses into one shield miss and 249 shield hits. In my experience, adding a shield improves global hit ratio by 8-15 points immediately, with the biggest gains on mid-popularity content (the long tail that is requested 10-100 times per hour). The math is simple: without shielding, your effective cache size is fragmented across 250 caches. With shielding, it is consolidated. For a detailed architecture discussion, see Origin Shield and Multi-Tier Caching: Reducing Origin Load at Scale.

Request Coalescing and Collapsed Forwarding

Enable collapsed forwarding (also called request coalescing or origin request collapsing) at both shield and edge. When 50 users request the same expired object within a 100ms window, only one request should go to origin; the other 49 should wait for the single origin response. Without this, a cache stampede during a flash sale can generate thousands of origin requests for one URL. Most major CDNs enable this with a single toggle, but you must verify it works for both MISS and REVALIDATE states. Combine this with tiered cache — where edge PoPs fetch from a regional parent cache, not directly from the shield — to get a second layer of consolidation.

Optimization Levers Typical Hit Ratio Gain Origin Load Reduction Implementation Risk
Query String & Cookie Key Normalization +8% to +18% 30-50% Low - Edge config only
TTL Stratification + Surrogate Key Purging +7% to +15% 40-60% Medium - Requires purge pipeline
Origin Shield + Tiered Cache +8% to +15% 60-85% Low - Single toggle + routing
Stale-While-Revalidate & Stale-If-Error +3% to +7% 20-35% on revalidation Low - Header change
Negative Caching (404/301) & Range Coalescing +2% to +5% 15-25% on error traffic Low - Cache rule

The table above reflects composite data from three migrations I led from ~60% to 90%+. The combined effect is multiplicative, not additive, because each layer reduces the miss population the next layer sees.

Serving Stale on Error and Revalidation Windows to Mask Origin Latency

A cache that serves errors during an origin outage is not helping your hit ratio or your users. Two HTTP directives, if used correctly, can hide origin latency and failures entirely from the user while still keeping content fresh.

stale-while-revalidate allows the edge to serve a stale object immediately while it fetches a fresh copy in the background. The user never waits. From a hit ratio perspective, this turns a synchronous REVALIDATE (which counts as a miss in many dashboards) into an asynchronous HIT. I recommend stale-while-revalidate=600 for pages and 30 for APIs. This pattern is documented well by web.dev Articles on caching and is fully supported in the HTTP Working Group Specifications for Cache-Control.

stale-if-error is even more critical. It instructs the CDN to serve stale content if origin returns 5xx or times out. Without it, a 2-second origin blip becomes 250 PoPs serving 500 errors. With stale-if-error=86400, those PoPs serve the last good version for up to a day. For deeper implementation patterns, Stale-While-Revalidate: The CDN Pattern That Eliminates Cache Misses walks through edge worker logic for background revalidation. I also advise adding must-revalidate only where legally required; otherwise it disables stale serving.

// Cloudflare Worker / Fastly Compute@Edge: serve stale while fetching fresh
async function fetchWithStaleWhileRevalidate(request, cache) {
  let cached = await cache.match(request);
  if (cached) {
    const age = Date.now() - Date.parse(cached.headers.get('date'));
    const sMaxAge = parseSMaxAge(cached.headers.get('cache-control'));
    // Serve stale instantly, revalidate in background
    if (age > sMaxAge * 1000 && age < (sMaxAge + 600) * 1000) {
      event.waitUntil(fetchAndPut(request, cache));
      // Add warning header for observability
      cached = new Response(cached.body, cached);
      cached.headers.set('X-Cache-Status', 'STALE-WHILE-REVALIDATE');
      return cached;
    }
    if (age <= sMaxAge * 1000) {
      cached.headers.set('X-Cache-Status', 'HIT');
      return cached;
    }
  }
  return fetchAndPut(request, cache);
}

One nuance: to make stale serving observable, always emit an X-Cache-Status or Age header and log it. Otherwise your hit ratio will appear to improve artificially because stale hits are counted as hits, and you will not know what percentage of traffic is actually stale.

Micro-Optimizations That Compound: Compression, Range Requests, and Negative Caching

The last 5% — from 90% to 95% — comes from handling edge cases that most teams ignore. Each is small alone, but together they close the gap.

Normalize Encodings and Collapse Range Requests

If you let the CDN vary on Accept-Encoding without normalization, you will cache three copies of every asset (br, gzip, identity). Configure the CDN to normalize to Brotli and Gzip at the edge and compress on the fly. The client still gets the best encoding it supports, but you store one or two variants, not three. Similarly, video and large file delivery suffers from range request fragmentation. A request for Range: bytes=0-1048575 and Range: bytes=1048576-2097151 should not create two cache objects. Enable range request coalescing so the CDN caches the full object and slices ranges at the edge. I've seen this improve hit ratio by 3-4 points on media-heavy sites.

Cache Your Failures: Negative Caching

404s, 301s, and 5xx error pages are often the most origin-intensive traffic, especially during crawls or attacks. If you do not cache them, every bot that hits /old-product-xyz goes to origin. Set explicit negative caching TTLs: Cache-Control: public, s-maxage=60 for 404s (short, to allow recovery when content appears) and s-maxage=3600 for permanent 301s. At Fastly or Akamai, this is a one-line VCL/property rule. Combine with request coalescing and you will see origin 404 load collapse by 80%.

Finally, audit your Cache-Control: private footprint. I once found a cart-count API that set private on every response, including an empty cart, which forced 12% of all page views to bypass the cache for the surrounding page because the browser request included Authorization and the CDN was configured to bypass on that header. The fix was to split the endpoint into a public template (s-maxage=300, stale-while-revalidate) and a tiny private JSON fetch for the count, wrapped in an edge include (ESI). That single change reclaimed 4 points of global hit ratio.

Reaching 95% is not a one-time project. It requires continuous monitoring of cache status by content type, route, and PoP, plus alerting when a new header or parameter fragments the key space. Build a dashboard that tracks hit ratio by content-type and surrogate-key, not just globally, and review miss reasons weekly. The gains compound — and so do the egress and origin cost savings. For a cost lens on these improvements, CDN Cost Optimization: Egress Reduction, Compression and Request Coalescing details how hit ratio directly maps to egress bills.

Frequently Asked Questions

What is a realistic cache hit ratio target for a dynamic e-commerce or media site?

For truly static assets (images, JS, CSS with hashes), aim for 98-99.5%. For the blended global hit ratio including HTML and APIs, 90-95% is excellent for most dynamic sites. If your site requires heavy personalization, 85-88% may be realistic before you implement edge personalization. The key is to measure hit ratio by content type — a 95% blended number that hides a 40% hit ratio on your product API is not a win.

Will increasing TTLs to improve hit ratio cause stale content to be shown to users?

Only if you increase TTLs without adding an invalidation mechanism. The correct pattern is to use long s-maxage values (86400s to 604800s) combined with surrogate keys and active purging on data change. When a product price changes, you purge surrogate-key: price-123 and all edge nodes invalidate that object within 150-300ms. This gives you the hit ratio of a long TTL with the freshness of a short one. stale-while-revalidate further masks revalidation latency.

Why does my hit ratio drop significantly during traffic spikes even though content is cacheable?

This is typically a thundering herd or coalescing issue, not a cacheability issue. When an object expires, thousands of edge nodes request it from origin simultaneously if you lack an origin shield and request collapsing. A second cause is cache key fragmentation spiking under load — for example, a flood of unique utm_ parameters from a viral campaign. Check your MISS reason breakdown during the spike and verify that shield caching and collapsed forwarding are enabled and functioning.

Should I include query strings or cookies in the cache key for personalized content?

As a rule, no. Strip all query strings and cookies for public cacheable content and only include the minimal normalized values that actually change the response. For personalization (e.g., A/B test bucket or logged-in state), compute a normalized edge value like x-variant: A and include that, not the raw cookie. Including raw cookies or full query strings is the most common reason I see sites stuck at 60-70% hit ratio — it creates a near-infinite key space for identical objects.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker