CDN Cache Analytics: Mining Hit/Miss Data for Cache Policy Optimization

Back in 2019, a large e-commerce client called me at 2 AM because their origin was melting down during a flash sale. Their dashboard showed a healthy 94% cache hit rate, so they couldn't understand why. When we dug into the raw logs, we found that the 6% of misses were all for the same 40 product SKUs, hitting origin thousands of times per second. That night taught me that a single hit rate number is almost useless without the story behind it, and that’s exactly what good cache analytics is for.

Why Your Hit Ratio Isn't Telling the Whole Story

Most teams I talk to track one metric: overall hit ratio. It’s easy to calculate, easy to put on a dashboard, and easy to misinterpret. A 90% hit rate sounds great until you realize it’s averaged across static assets, API calls, images, and HTML that all behave completely differently. Your images might be caching at 99% while your API responses are at 35%, and the average hides the problem where it hurts most — at the origin. If you’re serious about cache analytics, you have to slice that number by content type, URL pattern, cache status, and even POP.

I made this mistake early in my career. We were reporting a global hit rate for a video platform and patting ourselves on the back. Then we started breaking it down by country and discovered that our edge nodes in Southeast Asia were sitting at 62% because we’d sized the cache incorrectly there. The global average was being propped up by North America. We weren’t losing much traffic volume, but we were paying a fortune in origin egress and our viewers in that region were seeing rebuffering. You can’t do proper hit rate analysis without that dimensional breakdown, and you can’t get that without the right logs in the first place.

For this to work, you need logging that captures more than just HIT or MISS. At a minimum, you want the cache status as reported by the edge (HIT, MISS, EXPIRED, BYPASS, STALE), the request URI and its normalized form, the Cache-Control and CDN-Cache-Control response headers, Age, TTL remaining, and the POP/edge node ID. If your current pipeline only gives you aggregated counters, you’re flying blind. That’s why getting your CDN Real-Time Log Streaming Architecture: From Edge Events to Analytics in Seconds right is so critical — you need those events in near real-time, not rolled up every five minutes. The HTTP Working Group Specifications are a great reference for which headers actually dictate cacheability versus what your CDN might be interpreting.

-- ClickHouse SQL for dimensional hit rate analysis
-- Don't average the averages; weight by request count
SELECT
    content_type,
    edge_pop,
    cache_status,
    count() AS requests,
    countIf(cache_status = 'HIT') / count() AS hit_ratio,
    avg(origin_time_ms) AS avg_origin_latency_ms
FROM cdn_access_logs
WHERE timestamp >= now() - INTERVAL 1 HOUR
GROUP BY content_type, edge_pop, cache_status
HAVING requests > 1000
ORDER BY requests DESC
LIMIT 100;

Classifying Misses: The Foundation of Miss Optimization

Once you’ve got your hit rate broken down, the real work starts: figuring out *why* you missed. Not all misses are equal, and treating them as one bucket is the fastest way to waste engineering time. Over the years I’ve come to bucket misses into at least five distinct categories, and each one points to a completely different fix. If you don’t classify them, you’ll end up increasing TTLs when you should be fixing your Vary headers, or adding origin shielding when you should be normalizing query strings.

Here’s how I usually tag them in our analytics pipeline. Cold misses are the first fetch after a purge or deployment — they’re unavoidable but should be rare. TTL-expired misses mean your content was cached but went stale; that’s a tuning opportunity. Origin bypass misses happen when a request was deliberately sent to origin because of a Cache-Control: no-store, private, or a CDN bypass rule. Then you’ve got uncacheable misses due to cookies, Authorization headers, or query string variance. Finally, there are tiered misses where the edge missed but the shield or parent cache hit — those shouldn’t even count against your origin the same way. When we implemented this taxonomy for a news site, we found 41% of their “misses” were just tiered misses that never touched origin. Their true origin load was half what they thought.

Building that classification doesn’t have to be complex, but it does require you to join your access logs with your cache policy configuration. Your log aggregation layer has to handle billions of rows without sampling away the rare events that matter most. I’ve seen teams sample at 1:100 and then wonder why they can’t reproduce a cache poisoning incident that only affected 0.05% of URLs. If you’re worried about cost, read up on CDN Log Sampling Strategies: Reducing Storage Costs Without Losing Signal before you blindly turn on sampling — and make sure your pipeline itself is solid by reviewing your CDN Log Aggregation Architecture: Collecting Billions of Events Without Dropping Data. For front-end cache semantics, the web.dev Articles on HTTP caching give a practical look at how browsers and CDNs interpret those same headers.

# Python example: classifying CDN cache misses from structured logs
import re

def classify_miss(log):
    if log['cache_status'] != 'MISS':
        return log['cache_status']
    cc = log.get('cache_control', '')
    if 'no-store' in cc or 'private' in cc:
        return 'BYPASS_HEADER'
    if log.get('request_cookie'):
        return 'UNCACHEABLE_COOKIE'
    if log['age'] == 0 and log['ttl_remaining'] == 0:
        # Check if it was previously cached and expired
        return 'TTL_EXPIRED' if log['was_cached'] else 'COLD_MISS'
    if re.search(r'\?utm_|fbclid|gclid', log['uri']):
        return 'QUERY_VARIANCE'
    if log['parent_cache_status'] == 'HIT':
        return 'TIERED_MISS'
    return 'OTHER_MISS'

# Example: df['miss_class'] = df.apply(classify_miss, axis=1)

From Miss Patterns to Cache Policy Tuning

Now you’ve got clean, classified data — what do you actually change? This is where cache policy tuning stops being guesswork and becomes iterative engineering. The goal isn’t to hit 100%; it’s to protect your origin from the most expensive misses while keeping content fresh. I’ve learned that the cheapest wins almost never involve globally increasing TTL. They’re usually about fixing cache keys and honoring the right headers.

Take query string variance, for example. On one SaaS app I audited, 28% of all misses were from marketing parameters like utm_source and fbclid creating thousands of cache key variants for the same page. Their cache key included the full query string by default. We changed the policy to include only an allowlist of query params (like lang and page) and to strip the rest at the edge, then set a separate, short TTL for the HTML shell. Origin requests dropped by a third overnight with no code change on their app servers. That’s a classic miss optimization win that you’d never find without analytics.

Tables like the one below are how I usually present this to teams. Instead of showing a single hit rate, I show the miss breakdown with its real cost and the policy fix tied to it. It turns an abstract analytics discussion into a prioritized backlog.

Miss Category Share of All Misses Avg Origin Latency Origin Shield Hit? Recommended Policy Tuning
Query String Variance 28.4% 412 ms No Normalize cache key; allowlist only lang, page, sort params
TTL Expired (30s TTL) 22.1% 185 ms Yes (71%) Increase edge TTL to 300s, use stale-while-revalidate 600s
Bypass: Cookie / Auth 19.7% 250 ms No Split traffic: cache static shell, bypass only API sub-path
Cache-Control: private/no-store 15.3% 310 ms No Fix origin to send public, max-age=60 for anonymous users
Cold Miss (Post-Purge) 8.5% 530 ms No Use soft purge + origin shielding; stagger deployments

Putting It Into Practice: Automating Miss-Driven Policy Updates

Reading miss classifications in a dashboard is one thing — actually wiring them into an automated cache policy loop is where most teams stall. You don't need a massive ML pipeline to start. What you need is reliable log aggregation and a job that runs every 15-30 minutes to reclassify objects based on recent behavior. We've had the best luck pulling cache_status, origin_response_time, send_time, and the full Cache-Control / Vary headers into ClickHouse, then letting a scheduled query promote or demote objects between cache tiers.

The trick is to stop thinking in static TTLs and start thinking in dynamic TTL buckets. If an object had fewer than 3 hits in the last 24 hours but caused 50+ origin fetches due to low TTL churn, it's a candidate for a longer TTL or even stale-while-revalidate. Conversely, if you're seeing a 94% hit ratio but your byte hit ratio is sitting at 61%, you've likely got a long tail of large, cold objects polluting your cache — don't be afraid to set those to no-store or a 60s TTL. When you correlate this with Clickstream Analysis at CDN Edge: Understanding User Navigation Patterns, you can actually predict which assets will be requested together and pre-warm them as a group instead of one-by-one.

Here's the ClickHouse SQL we run to generate our hourly miss optimization feed. It classifies every miss and recommends a policy action — we push the output directly to our cache rule API:

-- Hourly miss classification + policy recommendation
WITH classified AS (
  SELECT
    url_path,
    cache_status,
    count() AS reqs,
    uniq(client_ip) AS unique_users,
    avg(origin_time_ms) AS avg_origin_ms,
    -- Classify the MISS type
    multiIf(
      cache_status = 'MISS' AND upstream_cache_status = 'HIT', 'tier_miss',
      cache_status = 'EXPIRED', 'expired',
      cache_status = 'BYPASS', 'uncacheable',
      cache_status = 'MISS' AND age_header = 0, 'cold_miss',
      'other'
    ) AS miss_type
  FROM cdn_access_logs
  WHERE timestamp >= now() - INTERVAL 1 HOUR
    AND cache_status != 'HIT'
  GROUP BY url_path, cache_status, miss_type
)
SELECT
  url_path,
  miss_type,
  reqs,
  avg_origin_ms,
  multiIf(
    miss_type = 'expired' AND reqs > 100, 'increase_ttl_to_3600',
    miss_type = 'tier_miss' AND reqs > 50, 'enable_tiered_cache',
    miss_type = 'uncacheable' AND unique_users < 5, 'add_surrogate_key',
    miss_type = 'cold_miss' AND avg_origin_ms > 250, 'enable_stale_while_revalidate_86400',
    'no_action'
  ) AS recommended_action
FROM classified
WHERE reqs > 20
ORDER BY reqs DESC
LIMIT 500;

Advanced Patterns: When Your Cache Metrics Lie to You

Once you've nailed the basics, you'll start hitting weird edge cases that make your hit ratio look fantastic while your origin is still on fire. My favorite is the "ghost hit" — Cloudflare, Fastly, and Akamai will all report a HIT for a stale object served via stale-while-revalidate or stale-if-error, which is technically correct, but it hides the fact that you're doing an async revalidation on every single request. We saw a customer bragging about an 88% hit ratio while their origin was still handling 1,200 RPS of background revalidations for a supposedly cached API. You’ve got to split your HITs into HIT_SYNC vs HIT_STALE if your provider supports it, or you'll miss it entirely.

Another killer is the thundering herd on highly expired content. If you've got an object with a 10s TTL getting 800 req/s, you're not just getting misses — you're getting 70-80 simultaneous origin fetches every 10 seconds because of collapsed forwarding timeouts. The fix isn't a longer TTL, it's request coalescing + a 2-3 second stale-while-revalidate grace period. We typically see origin load drop by 65-80% just by adding stale-while-revalidate=5 to those hot, short-TTL endpoints without touching the actual freshness. And if you're mapping full User Journey Analytics from CDN Logs: Mapping the Path to Conversion, you'll notice these herds almost always cluster around the first 2-3 pages of a session — perfect candidates for edge prefetch.

Finally, don't ignore your BYPASS rate. If more than 12-15% of your total requests are BYPASS due to cookies or Authorization headers, you've got a cache key problem, not a traffic problem. We've audited sites where __utm* and _fbp cookies forced a BYPASS on 40% of static assets that should've been public. Stripping those at the edge with a simple Vary: Cookie cleanup or moving to cookie-less asset domains bumped their effective hit ratio from 73% to 91% overnight and cut origin egress costs by about 31%. For anything tied to revenue, tie this back to Conversion Attribution from CDN Edge: First-Party Tracking Without Client-Side Scripts so you don't accidentally strip a cookie that's actually needed for attribution.

Frequently Asked Questions

What's a good cache hit ratio to aim for after miss optimization?

It depends entirely on content type. For static assets (images, JS, CSS), you should be targeting 92-97% egress hit ratio and 85%+ byte hit ratio. For APIs, 40-60% is often excellent if you've correctly classified uncacheable personalization. Don't chase 99% — if you're above 95% on a mixed site, you're probably over-caching and serving stale personalization data.

How do I handle cache misses caused by query string variations?

Normalize first, cache second. In our logs, we consistently see 25-40% of cold misses are just ?utm_source=... or ?fbclid=... variants of the same URL. Strip known marketing params at the edge before the cache lookup, and whitelist only the params that actually change content (like ?page= or ?color=). Then set your cache key to ignore the rest. One regex at the edge can eliminate thousands of duplicate cache entries per hour.

Should I use tiered caching or just increase TTLs?

Use both, but for different miss types. Tiered caching (origin shield) fixes tier_miss / inter-POP misses and typically cuts origin fetches by 30-50% with zero freshness risk — turn it on first. Longer TTLs fix expired misses but add staleness risk, so only increase them for objects you’ve verified are semi-static. If an object changes less than twice per day, a 1-hour TTL with stale-while-revalidate is almost always safer than a 5-minute TTL without it.

How long should I keep hit/miss logs for analysis?

Keep 7 days of full request-level logs hot in ClickHouse or BigQuery for real-time tuning, and 90 days of aggregated per-URL miss rollups for seasonality. You don't need raw logs beyond that — they get expensive fast at 10B+ rows/day. We downsample to 1% sampling for long-term storage and it’s still accurate enough to catch weekly patterns, like that Monday 9am cache flush that kills your hit ratio every week.

Related Articles

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