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.

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.

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;

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.

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.