Debugging CloudFront cache misses for images

A CloudFront image cache hit ratio below 90% is almost always a configuration problem, not a traffic problem — your images should be near-static, and every miss re-fetches from the origin (or re-runs a transcode), inflating latency and cost. This guide, part of AWS CloudFront Cache Behaviors for Media inside CDN & Edge Media Delivery, gives you a repeatable procedure: read three response headers to localize the miss, isolate which cache-key dimension is fragmenting the object, confirm the scope in CloudWatch, and apply the matching fix. The causes are finite and each has a clean remedy.

The three headers that localize a miss

Every CloudFront response carries diagnostic headers. Three of them answer “did this hit, how old is it, and which edge answered?”

  • x-cacheHit from cloudfront (served from the POP’s cache), Miss from cloudfront (went to origin), RefreshHit from cloudfront (revalidated a stale object), or Error from cloudfront.
  • age — seconds the object has lived in this POP’s cache. age: 0 on a supposed Hit means it was just (re)fetched. A rising age across repeat requests confirms a stable cached object.
  • x-amz-cf-pop — the edge location code (e.g. LHR50-C1). CloudFront caches per POP, so a Miss simply because your two requests hit different POPs is not a bug.
# Baseline probe: repeat the SAME request twice and read the diagnostic trio.
for i in 1 2; do
  curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
    https://d111111abcdef8.cloudfront.net/images/hero.jpg \
    | grep -iE 'x-cache|^age|x-amz-cf-pop|content-type|cache-control'
  echo "--- request $i ---"
done
# Healthy result: request 1 = Miss (age 0), request 2 = Hit (age rising), SAME pop.
# If request 2 is still a Miss on the same POP, the object is not being cached.
Cache-miss diagnosis decision tree Starting from a repeated Miss on the same POP, branch on whether the URL carries query strings, whether cookies are forwarded, whether Accept is over-keyed, and whether the TTL is too short — each leading to a specific fix. Persistent Miss, same POP URL has ?query strings? yes QueryString: none no Cookies forwarded + keyed? yes Cookie: none no Raw Accept keyed? yes normalize Accept no TTL too short / no-cache? yes raise origin max-age

Prerequisite checklist

Causes and fixes

1. Query strings in the cache key

If the image behavior’s Cache Policy sets QueryStringBehavior: all (or the legacy ForwardedValues forwards all query strings), then hero.jpg?v=1, hero.jpg?utm_source=x, and hero.jpg are three different cache objects. Analytics and cache-busting params multiply this endlessly.

# Prove it: the SAME image with two different junk query strings should NOT
# create two objects. If each is a fresh Miss, query strings are keying the cache.
curl -sI 'https://d111111abcdef8.cloudfront.net/images/hero.jpg?utm=a' | grep -i x-cache
curl -sI 'https://d111111abcdef8.cloudfront.net/images/hero.jpg?utm=b' | grep -i x-cache

Fix: Set QueryStringBehavior: none on the media Cache Policy. If you genuinely resize by query (?w=800), whitelist only the real sizing parameters and nothing else.

2. Cookies forwarded into the key

A Cache Policy with CookieBehavior: all fragments the cache per unique cookie value — meaning per logged-in user, per session, per A/B bucket. Images become effectively uncacheable.

Fix: Set CookieBehavior: none on the media behavior. Media never needs cookies; if the origin requires one for auth, forward it via the Origin Request Policy without keying it.

3. Accept over-keying

Adding Accept to the cache key is required for AVIF/WebP negotiation — but keying the raw Accept string fragments the cache across every browser build’s slightly different header. You get correct formats and a terrible hit ratio simultaneously.

# Two real-world Accept strings that select the same format should hit the same object.
# If both Miss, you are keying the raw string, not a normalized token.
curl -sI -H 'Accept: image/avif,image/webp,image/apng,*/*;q=0.8' \
  https://d111111abcdef8.cloudfront.net/images/hero.jpg | grep -i x-cache
curl -sI -H 'Accept: image/avif,image/webp,*/*' \
  https://d111111abcdef8.cloudfront.net/images/hero.jpg | grep -i x-cache

Fix: Normalize Accept to image/avif/image/webp/image/jpeg in a viewer-request CloudFront Function so at most three variants exist. The rewrite must happen on viewer-request, because that is the only event that runs before the cache key is computed; a normalization applied on origin-request fixes what the origin sees but leaves the fragmented key untouched. The matrix below shows the same four viewers landing on four objects when the raw string is keyed and on two when the token is.

Raw Accept versus normalized token as a cache-key dimension Four viewer Accept headers from Chrome, Edge, Firefox and Safari are shown. Keyed as raw strings they produce four separate cache objects and four origin fetches. Normalized to a token, the three AVIF-capable browsers collapse into one image/avif object and Safari into one image/webp object, for two objects total. Viewer Accept header Raw string as cache key Normalized token as key Chrome 121 image/avif,image/webp,image/apng,*/*;q=0.8 object #1 Edge 120 image/avif,image/webp,*/*;q=0.8 object #2 Firefox 133 image/avif,image/webp,*/* object #3 Safari 16.4 image/webp,*/*;q=0.8 object #4 image/avif one object, three viewers image/webp one object 4 objects, 4 origin fetches 2 objects, 2 origin fetches Every browser build ships a slightly different Accept string; only a normalized token bounds the variant count.

Real traffic makes this worse than the four rows suggest. Chrome, Edge, Firefox, Safari, in-app WebViews, and every crawler each emit their own ordering and q= weights, so a raw-keyed Accept routinely produces a dozen or more objects per image. The hit ratio degrades roughly in proportion, and each extra variant is another cold object that must be filled from the origin at every POP that serves it.

4. TTL too short (or origin no-cache)

If the origin sends Cache-Control: max-age=60 — or nothing, so DefaultTTL is a low value — objects expire before the next viewer arrives, especially on low-traffic POPs. RefreshHit in x-cache with frequent revalidation is the tell.

Fix: Serve content-hashed URLs with Cache-Control: public, max-age=31536000, immutable from the origin, per best practices for setting max-age on CDN media assets. Confirm the origin is not sending no-cache/private, which suppress caching unless MinTTL overrides them.

5. Per-POP cold cache (a non-problem)

CloudFront has 600+ POPs and each caches independently. An image requested only once an hour, spread across many POPs, will show frequent Misses that are expected — the object simply ages out or was never warm at that edge. This is not a misconfiguration; it is the nature of a distributed cache with low request density.

Fix: Nothing to fix per se, but you can raise the effective hit ratio for infrequently requested assets by enabling Origin Shield (a designated mid-tier cache region) so POP misses collapse to a single shielded origin fetch instead of hitting your origin from every edge.

Origin Shield collapses per-POP cold misses On the left, four CloudFront edge locations each miss and each issues its own fetch to the S3 origin, so origin load scales with the number of POPs. On the right, the same four POPs fetch through a regional Origin Shield that holds one warm copy and makes a single request to the origin. Without Origin Shield With Origin Shield LHR50 CDG52 IAD79 NRT57 Origin S3 bucket 4 cold POPs, 4 origin fetches origin load scales with POP count LHR50 CDG52 IAD79 NRT57 Origin Shield regional cache one warm copy Origin S3 4 cold POPs, 1 origin fetch the shield absorbs the fan-out

Origin Shield does not change the x-cache header a viewer sees — an edge miss that is satisfied by the shield still reports Miss from cloudfront, because the header describes the POP’s own cache, not the mid-tier’s. What changes is origin request volume and origin latency: the shield keeps one warm copy in a region you choose (put it near the origin, not near your users), and it also collapses concurrent misses for the same object into a single upstream fetch. On a transcoding origin — for example the Lambda@Edge AVIF pipeline — that collapsing is the difference between one encode and forty for a newly published hero image.

Confirm scope with CloudWatch and logs

Header probing tells you about one URL; CloudWatch tells you about all of them. The CacheHitRate metric (percentage of viewer requests served from the edge) is the headline number.

# Pull the last 24h of CacheHitRate for the distribution (metrics live in us-east-1).
aws cloudwatch get-metric-statistics --region us-east-1 \
  --namespace AWS/CloudFront --metric-name CacheHitRate \
  --dimensions Name=DistributionId,Value=E1EXAMPLE2ID Name=Region,Value=Global \
  --start-time "$(date -u -d '24 hours ago' +%FT%TZ)" \
  --end-time   "$(date -u +%FT%TZ)" \
  --period 3600 --statistics Average --output table

To find which images miss, query the standard access logs (the x-edge-result-type field records Hit, Miss, RefreshHit, LimitExceeded, etc.):

# With logs in Athena, rank the worst-caching image URIs by miss count.
# x-edge-result-type = Miss isolates true origin fetches; sc_content_type filters to images.
#   SELECT cs_uri_stem, count(*) AS misses
#   FROM cloudfront_logs
#   WHERE x_edge_result_type = 'Miss' AND sc_content_type LIKE 'image/%'
#   GROUP BY cs_uri_stem ORDER BY misses DESC LIMIT 20;

x-edge-result-type has more values than x-cache exposes, and the distinction tells you why the object left the cache:

x-edge-result-type Meaning What it implies for images
Hit Served from the POP, no origin contact Healthy; the target state for every image
RefreshHit Object was stale, revalidated with the origin, still fresh TTL is shorter than the request interval — raise max-age
Miss Not in the POP cache; fetched from origin Key fragmentation or a genuinely cold object
LimitExceeded A CloudFront limit was hit (e.g. Lambda@Edge body size) An edge function is failing on specific large files
CapacityExceeded The origin or the edge returned 503 under load Origin cannot absorb the miss rate — enable Origin Shield
Error The request failed before or during the origin fetch Origin timeouts or OAC/permission errors, not caching

A hit ratio that jumps immediately after tightening the Cache Policy confirms the fix; one that stays flat points at a genuinely sparse access pattern (cause 5) rather than key fragmentation. Compare Hit against RefreshHit before concluding anything: a distribution reporting 92% CacheHitRate where a third of those are RefreshHit is still making an origin round trip for a third of its traffic, because CacheHitRate counts revalidations as hits.

Verification commands

# After the fix: the same URL should Hit on the second same-POP request, and two
# junk query strings / two equivalent Accept strings should share one object.
curl -sI 'https://d111111abcdef8.cloudfront.net/images/hero.jpg' | grep -iE 'x-cache|^age'
curl -sI 'https://d111111abcdef8.cloudfront.net/images/hero.jpg' | grep -iE 'x-cache|^age'

# Inspect the live Cache Policy to confirm cookies/query strings are 'none'
# and only Accept is whitelisted.
aws cloudfront get-cache-policy --id <cache-policy-id> \
  --query 'CachePolicy.CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin'

Common mistakes

1. Comparing Miss across different POPs

Anti-pattern: Concluding the cache is broken because two curls returned Miss — from two different x-amz-cf-pop values.

Effect: Wasted debugging; the per-POP cache is behaving correctly.

Fix: Always compare repeat requests that show the same x-amz-cf-pop. Pin to one POP (same network/region) when reproducing.

2. Reading age as a global TTL

Anti-pattern: Treating age as the object’s age across CloudFront.

Effect: Misdiagnosing a fresh object at a cold POP as “not caching.”

Fix: age is per-POP. Use CacheHitRate for the fleet-wide picture.

3. Fixing symptoms with invalidation

Anti-pattern: Running create-invalidation whenever an image looks stale or misses.

Effect: Wildcard invalidations throw away warm cache fleet-wide, spiking origin load and worsening the very hit ratio you are chasing.

Fix: Version URLs and correct the Cache Policy; reserve invalidation for emergencies, as covered in the parent guide.

4. Leaving a device-type or User-Agent header in the cache key

Anti-pattern: Adding CloudFront-Is-Mobile-Viewer (or worse, raw User-Agent) to the image behavior’s Cache Policy “just in case” the origin serves something different to phones.

Effect: Raw User-Agent is effectively unbounded and multiplies every image by thousands of variants; the device-type booleans still quadruple the key for no benefit, because responsive images are selected by srcset in the browser, not by the server.

Fix: Remove them from the media behavior. Size selection belongs in srcset and sizes, so the only header the image key ever needs is a normalized Accept.

5. Debugging a URL that a function rewrites

Anti-pattern: Comparing curl output for /images/hero.jpg when a viewer-request function rewrites the URI or appends a normalization suffix before the cache lookup.

Effect: The object you probe is not the object being keyed, so Hit/Miss results look random and every hypothesis appears to be confirmed and refuted in turn.

Fix: Log the post-function request.uri and cache-key inputs from the function itself, then probe the rewritten path. Disable the association briefly on a test behavior if you need a clean baseline.