Customizing Cache Keys to Improve Hit Ratio

A low CDN cache hit ratio almost always traces back to one root cause: the cache key carries noise that splits a single cacheable object into thousands of near-duplicate variants. After working through this guide you will be able to measure your current hit ratio, isolate the exact inputs fragmenting your cache (UTM parameters, session cookies, Accept-Encoding, User-Agent), and build a custom cache key on Cloudflare and CloudFront that strips that noise while preserving every variant your users actually need.

This procedure sits inside the broader discipline of Cache Key & Vary Configuration and pairs directly with disciplined origin headers. If your origin is emitting inconsistent Cache-Control or Set-Cookie on cacheable assets, fix that first using Setting Cache-Control Headers for Static and Dynamic Content — a clean key on a poisoned origin still misses.

Key objectives:

  • Measure baseline hit ratio and sample cf-cache-status / x-cache to find fragmentation
  • Classify every cache-key input as meaningful, noise, or dangerous
  • Deploy a custom cache key on Cloudflare and a CloudFront cache policy via Terraform
  • Verify the lift and avoid serving the wrong variant or opening a cache-deception hole
Cache key fragmentation before and after normalization Left side shows one URL exploding into many cache entries because UTM params, cookies and User-Agent are in the key. Right side shows a normalized key collapsing them into one cache hit. One logical asset, two cache-key strategies BEFORE: raw key (noisy) key = url + all query + cookie + UA /app?utm_source=ads → MISS /app?utm_source=email → MISS /app (cookie sid=a1) → MISS /app (cookie sid=b2) → MISS 4 entries, 0 hits hit ratio collapses AFTER: normalized key key = url + ?lang + Accept-Encoding /app (en, br) utm_* stripped · cookie ignored 1 entry → HIT, HIT, HIT 1 entry, 3 of 4 hits hit ratio climbs

Prerequisites and environment setup

You need administrative access to your CDN configuration and a way to read edge response headers. Confirm the following tooling before you start:

# Verify local tooling versions
curl --version | head -1        # any modern curl (7.68+)
dig -v 2>&1 | head -1           # bind-tools / dnsutils
terraform version               # >= 1.5 for CloudFront work
npx wrangler --version          # >= 3.x for Cloudflare Workers/rules

For Cloudflare, custom cache keys require either a Cache Rule (available on all plans for a limited key set) or the Enterprise “Cache Key” page rule / cache.cacheKey Worker override for full control. Confirm your zone is proxied (orange-cloud) so traffic actually transits the edge:

dig +short app.example.com    # must resolve to Cloudflare anycast IPs, not your origin

For CloudFront you need IAM permissions for cloudfront:CreateCachePolicy, cloudfront:UpdateDistribution, and the distribution ID. Cache behavior is governed by a cache policy (defines the key + TTL) distinct from an origin request policy (defines what is forwarded to origin but is not part of the key) — conflating the two is the most common mistake.

Step 1: Measure the baseline hit ratio

Never tune a key blind. Pull the current ratio from analytics first, then corroborate with header sampling so you know the number is real.

# Cloudflare GraphQL Analytics: cached vs total requests for a zone, last 24h
curl -s https://api.cloudflare.com/client/v4/graphql \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"query":"query{viewer{zones(filter:{zoneTag:\"'"$ZONE_ID"'\"}){httpRequests1dGroups(limit:1,filter:{date_geq:\"2026-06-19\"}){sum{cachedRequests requests}}}}}"}' \
  | python3 -c 'import sys,json;d=json.load(sys.stdin)["data"]["viewer"]["zones"][0]["httpRequests1dGroups"][0]["sum"];print(f"hit ratio = {d[\"cachedRequests\"]/d[\"requests\"]:.1%}")'

Expected output is a single line such as hit ratio = 41.8%. For static-heavy assets anything below ~85% is a red flag. Record this number; it is your before-state.

Step 2: Sample cf-cache-status to find the fragmenting input

Hit one URL repeatedly with controlled variations and watch which header change flips the result from HIT to MISS. The header that toggles the status is in your cache key.

URL="https://app.example.com/landing"

# Baseline: two identical requests. Second should be HIT.
for i in 1 2; do curl -sI "$URL" | grep -i cf-cache-status; done

# Toggle a UTM param — if this re-MISSes, query string is unnormalized
curl -sI "$URL?utm_source=newsletter" | grep -i cf-cache-status
curl -sI "$URL?utm_source=twitter"    | grep -i cf-cache-status

# Toggle a cookie — if this re-MISSes, cookies are in the key
curl -sI "$URL" -H 'Cookie: sid=aaaa' | grep -i cf-cache-status
curl -sI "$URL" -H 'Cookie: sid=bbbb' | grep -i cf-cache-status

# Toggle User-Agent
curl -sI "$URL" -A 'Mozilla/5.0 fakeA' | grep -i cf-cache-status
curl -sI "$URL" -A 'Mozilla/5.0 fakeB' | grep -i cf-cache-status

On CloudFront the equivalent header is x-cache: Hit from cloudfront versus Miss from cloudfront. Build a small matrix of what you observe:

Input varied Re-MISS? In cache key today? Should it be?
utm_* query params yes yes no — pure noise
gclid / fbclid yes yes no — pure noise
?lang= / ?v= yes yes yes — meaningful variant
Session cookie yes yes no — defeats shared cache
Accept-Encoding maybe maybe yes — br vs gzip differ
User-Agent yes yes only if you serve device variants

Every “yes / no” row in the last two columns is a fragmenting input you must strip.

Step 3: Build the Cloudflare custom cache key

Use a Cache Rule to normalize the key. The pattern below keeps only an allowlist of query parameters, ignores cookies, and includes Accept-Encoding so Brotli and gzip stay correctly separated. Configure this in Caching → Cache Rules or via the API:

{
  "expression": "(http.host eq \"app.example.com\")",
  "description": "Normalize cache key for landing assets",
  "action": "set_cache_settings",
  "action_parameters": {
    "cache": true,
    "cache_key": {
      "ignore_query_strings_order": true,
      "custom_key": {
        "query_string": { "include": ["lang", "v"] },
        "header": { "include": ["accept-encoding"] },
        "cookie": { "include": [] },
        "user": { "device_type": false, "geo": false, "lang": false }
      }
    }
  }
}

The query_string.include allowlist is the lever that kills UTM fragmentation: anything not named is dropped from the key. Setting cookie.include to an empty array removes session cookies from the key so all users share one cached object. Keep accept-encoding in header.include rather than relying on a raw Vary, because Cloudflare normalizes encoding to a small set and a custom-key inclusion is more predictable than an origin-controlled Vary: User-Agent that explodes into thousands of variants.

If you need logic the rule engine cannot express, override cacheKey inside a Worker:

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    // Drop tracking params, keep meaningful ones
    const keep = new URLSearchParams();
    for (const k of ["lang", "v"]) {
      if (url.searchParams.has(k)) keep.set(k, url.searchParams.get(k));
    }
    url.search = keep.toString();
    const cacheKey = new Request(url.toString(), { method: "GET" });
    const cache = caches.default;
    let resp = await cache.match(cacheKey);
    if (!resp) {
      resp = await fetch(request);            // fetch with ORIGINAL request
      resp = new Response(resp.body, resp);
      resp.headers.set("Cache-Control", "public, max-age=3600");
      ctx.waitUntil(cache.put(cacheKey, resp.clone()));  // store under normalized key
    }
    return resp;
  }
};

Note the asymmetry: the cache is keyed on the normalized URL, but the origin fetch uses the original request so analytics and attribution still receive the UTM data. Side effect: the Worker now owns caching for this route, so any Cache-Control you set here wins over the origin.

Step 4: Build the CloudFront cache policy via Terraform

CloudFront expresses the same idea through a managed key allowlist. Define a cache policy and attach it to the behavior:

resource "aws_cloudfront_cache_policy" "normalized" {
  name        = "normalized-landing-key"
  default_ttl = 3600
  max_ttl     = 86400
  min_ttl     = 0

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = true   # adds normalized Accept-Encoding to key
    enable_accept_encoding_gzip   = true

    query_strings_config {
      query_string_behavior = "whitelist"
      query_strings { items = ["lang", "v"] }   # utm_*, gclid, fbclid excluded
    }
    headers_config { header_behavior = "none" } # keep User-Agent OUT of the key
    cookies_config { cookie_behavior = "none" } # session cookies excluded
  }
}

resource "aws_cloudfront_distribution" "site" {
  # ...origin and other config omitted...
  default_cache_behavior {
    target_origin_id       = "app-origin"
    viewport_protocol_policy = "redirect-to-https"
    cache_policy_id        = aws_cloudfront_cache_policy.normalized.id
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
  }
}

Apply and watch the plan touch only the cache policy and behavior:

terraform apply
# Expect: aws_cloudfront_cache_policy.normalized will be created
#         aws_cloudfront_distribution.site will be updated in place

If you still need UTM values at the origin (for server-side analytics) without putting them in the key, forward them through an origin request policy instead — that is the CloudFront equivalent of the Worker’s original-request fetch.

Verification

Re-run the toggle test from Step 2. The tracking-parameter and cookie variations must now return HIT on the second request, while lang still produces a fresh MISS:

URL="https://app.example.com/landing"
curl -sI "$URL?utm_source=a"        | grep -i cf-cache-status   # HIT (utm ignored)
curl -sI "$URL?utm_source=b"        | grep -i cf-cache-status   # HIT
curl -sI "$URL" -H 'Cookie: sid=x'  | grep -i cf-cache-status   # HIT (cookie ignored)
curl -sI "$URL?lang=de"             | grep -i cf-cache-status   # MISS then HIT (real variant)
curl -sI "$URL?lang=de"             | grep -i cf-cache-status   # HIT

Then re-pull the hit ratio from Step 1 after the cache has warmed for an hour. A correctly normalized key on a static landing asset routinely moves a ratio from the 40s into the 90s. Confirm encoding correctness so you did not collapse Brotli and gzip into one corrupt entry:

curl -sI "$URL" -H 'Accept-Encoding: br'   | grep -iE 'content-encoding|cf-cache-status'
curl -sI "$URL" -H 'Accept-Encoding: gzip' | grep -iE 'content-encoding|cf-cache-status'
# Each must report its own encoding; never gzip body served as br

Troubleshooting

Still MISSing after normalization

First confirm the object is even eligible. A Set-Cookie on the response or Cache-Control: private from origin makes the edge refuse to cache regardless of key. Inspect with curl -sI "$URL" | grep -iE 'set-cookie|cache-control' and strip the cookie at origin. Second, check ignore_query_strings_order (Cloudflare) is on — ?a=1&b=2 and ?b=2&a=1 otherwise hash to different keys. Third, verify your rule actually matches; a too-narrow http.host or path expression silently skips the asset.

Serving the wrong variant to users

If users report stale or mismatched language/currency content, you stripped a meaningful input. Audit which query params and headers genuinely change the response body and add them back to the include allowlist. The classic case is dropping Accept-Encoding: clients then receive a Brotli body labeled gzip and the browser fails to decode. Always keep encoding in the key. When in doubt, widen the key slightly — a marginally lower hit ratio beats a correctness bug.

Cache deception / poisoning risk

Aggressively forcing cacheability can expose private data. Cache deception attacks append a fake static suffix (e.g. /account.css) to a sensitive path, hoping the edge caches an authenticated response under a shared key. Never cache responses that carry Set-Cookie, Authorization-gated content, or Cache-Control: private. On Cloudflare, scope your Cache Rule to specific public paths and add an explicit bypass_cache_on_cookie for session identifiers on authenticated routes. Validate by requesting a logged-in URL through an anonymous client and confirming you get a MISS plus your login redirect — never another user’s data.

Hit ratio improved but origin load did not drop

You are likely purging too aggressively on every deploy, evicting warm objects. Align your invalidation cadence with the key changes documented in Purging Cloudflare Cache via API on Deploy so you purge only changed paths rather than the whole zone.

Frequently Asked Questions

Does removing query strings from the cache key break my analytics? No, if you key on the normalized URL but fetch the origin with the original request. The CDN serves a shared cached body while the original UTM parameters still reach your origin logs or analytics beacon. Only the cache lookup ignores them.

Should I ever include User-Agent in the cache key? Almost never raw — a raw User-Agent produces thousands of unique strings and shreds your hit ratio. Only include a normalized device-type signal (Cloudflare’s device_type, or a CloudFront CloudFront-Is-Mobile-Viewer header) when you actually serve distinct mobile and desktop responses for the same URL.

What is the difference between a custom cache key and a Vary header? Vary is an origin-controlled instruction telling the cache to split on a request header’s value, but it is coarse and easily explodes the cache. A custom cache key is CDN-side configuration giving you precise control over exactly which URL parts, params, headers, and cookies form the key. They overlap; prefer the explicit custom key for predictability.

How long until I see the hit-ratio improvement? The key change takes effect immediately for new requests, but the ratio is a moving average. Allow at least one full cache TTL plus your traffic’s natural diurnal cycle — typically a few hours to a day — before comparing the before and after numbers.

Can a custom cache key leak one user’s data to another? Yes, if you strip an input that distinguishes authenticated responses. Only normalize keys on genuinely public, non-personalized assets, and never cache any response carrying Set-Cookie or Cache-Control: private. Test with an anonymous client against an authenticated URL before rolling out.

Back to Cache Key & Vary Configuration