AWS CloudFront Cache Behaviors for Media

CloudFront’s default configuration is actively hostile to format-negotiated media: it strips the Accept header before it reaches your cache key, ignores Vary: Accept from your origin, and — unless you tell it otherwise — collapses every AVIF, WebP, and JPEG variant of a URL into a single cached object. This guide, part of CDN & Edge Media Delivery, covers how cache behaviors, Cache Policies, and Origin Request Policies actually decide what gets stored at the edge, and how to wire them so a modern browser gets AVIF, an older Safari gets WebP, and neither poisons the other’s cache. The rules here build directly on the origin-side Cache-Control headers for image and video assets — CloudFront’s TTLs and your max-age interact in ways that surprise most teams.

Concept & architecture

A CloudFront distribution is a list of cache behaviors, each matched by a path pattern. When a request arrives, CloudFront walks the behaviors in priority order and uses the first pattern that matches. Each behavior points at an origin and references two policy objects that decide caching: a Cache Policy (what goes into the cache key, plus the TTL floor/ceiling) and an Origin Request Policy (what CloudFront forwards to the origin, which may be a superset of the cache key).

The distinction between those two policies is the single most misunderstood part of CloudFront media delivery. The cache key determines whether two requests are the same cached object. The origin request determines what your origin sees. A header can be forwarded to the origin without being part of the cache key — and for headers like Authorization that is exactly what you want. But for format negotiation you need the opposite: Accept must be in the cache key, or CloudFront will serve whichever variant it happened to cache first.

Path patterns and precedence

Path patterns are evaluated most-specific-first only because you order them; CloudFront does not sort them for you. A behavior for /images/hero/* must sit above the catch-all * behavior, or the catch-all wins. Typical media distributions carry three or four behaviors:

  • /video/* — long-lived MP4/WebM segments, no query strings in the key, high TTL.
  • /images/* — Accept-keyed for AVIF/WebP negotiation, moderate TTL.
  • /api/* — no caching (managed CachingDisabled policy), all headers forwarded.
  • * — the default behavior, catch-all, usually HTML with a short TTL.

Path patterns are far less expressive than a regular expression. The only wildcards are * (any sequence, including /) and ? (exactly one character); there are no character classes, no alternation, and no anchoring beyond the implicit start-of-path. /images/* therefore matches /images/a/b/c.avif as well as /images/hero.avif, and there is no pattern that means “any .avif under any directory” other than the blunt *.avif. Matching is case-sensitive, so /Images/hero.avif falls through to the default behavior with its short HTML TTL — a classic source of “one image is uncached and nobody knows why”.

Two structural limits shape the design. A distribution supports 25 cache behaviors by default, which sounds generous until a team starts adding one per product area; and the default * behavior is always evaluated last regardless of where it sits in the list. Because the first match wins and CloudFront preserves your ordering literally, a behavior added at the bottom of the list during an incident may never fire. Treat behavior order as source-controlled configuration, not console clicks.

What actually goes into the cache key

CloudFront builds the cache key by concatenating a fixed set of components and hashing them: the distribution ID, the request’s host, the URI path, and then exactly the headers, cookies, and query strings named in the Cache Policy — nothing else. Header names are matched case-insensitively but header values are used verbatim, which is precisely why an unnormalized Accept fragments so badly: image/avif,image/webp and image/webp,image/avif are two different keys even though they mean the same thing.

Three details follow from that construction and routinely surprise people:

  • The HTTP method is part of the key, and only GET/HEAD are cached. A HEAD shares the key with its GET, so a curl -I warms the same object your browser will hit — which is what makes the verification commands later on meaningful.
  • Query string order is normalized, but values are not. CloudFront sorts whitelisted query parameters before hashing, so ?w=800&q=75 and ?q=75&w=800 collide correctly. It does not canonicalize casing or numeric formatting, so ?w=800 and ?w=0800 are distinct objects.
  • Vary from the origin never enters the key. CloudFront forwards the header to the viewer so the browser cache stays correct, and then ignores it for its own storage decision. Every other component of the key must be declared by you.

The last point is the whole reason the Accept-keyed Cache Policy exists as a separate configuration step rather than being inferred from your origin’s response.

CloudFront media request flow A left-to-right flow: viewer request enters CloudFront; a path-pattern match selects a cache behavior; the Cache Policy builds a cache key from URL, whitelisted headers such as Accept, and query strings; a cache hit returns the stored object while a miss triggers an Origin Request Policy to S3 or an edge function before storing the derivative. Viewer Accept: image/avif Behavior match path pattern /images/* first match wins Cache Policy key = URL + header: Accept + query allowlist Min/Default/Max TTL key HIT → serve from edge MISS Origin Request forward Accept + edge function S3 origin or Lambda@Edge derivative stored under the Accept-keyed object

Cache Policies vs legacy forwarded values

Before 2020, CloudFront configured caching inline on the behavior: a ForwardedValues block listing which headers, cookies, and query strings to forward and cache on. AWS deprecated that model in favour of reusable Cache Policies and Origin Request Policies. The legacy ForwardedValues conflated the cache key and the origin forward set — anything you forwarded was automatically part of the cache key. That is precisely why so many legacy distributions have catastrophic cache hit ratios: forwarding a cookie for the origin silently fragmented the cache by cookie.

The modern split lets you forward Accept to the origin (via the Origin Request Policy) and independently choose whether it keys the cache (via the Cache Policy). For format negotiation you want it in both. For an Authorization header you want it forwarded but never keyed.

Warning: If your distribution still uses ForwardedValues, the AWS console will not let you attach a Cache Policy until you migrate. Migrate deliberately — copying a legacy config that forwards Accept into a Cache Policy that does not key on Accept will silently break format negotiation, because the origin still sees Accept but CloudFront no longer stores variants separately.

Including Accept in the cache key

The mechanism that makes AVIF/WebP negotiation work on CloudFront is a Cache Policy whose HeadersConfig whitelists the Accept header. This is the CloudFront equivalent of honouring Vary: Accept — except CloudFront ignores the origin’s Vary: Accept entirely for cache-key purposes and requires you to declare the keyed headers explicitly.

// cache-policy-accept.json — Cache Policy that keys on Accept for image negotiation
{
  "Name": "media-accept-keyed",
  "Comment": "Keys image cache on Accept so AVIF and WebP are stored separately",
  "DefaultTTL": 86400,      // 1 day: used when the origin sends no Cache-Control
  "MaxTTL": 31536000,       // 1 year ceiling: caps an origin max-age that is too long
  "MinTTL": 1,              // never treat an object as fresh for 0s; 1s floor
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,    // adds Accept-Encoding: gzip normalization
    "EnableAcceptEncodingBrotli": true,  // and brotli — CloudFront normalizes both
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": { "Quantity": 1, "Items": ["Accept"] }  // the format-negotiation header
    },
    "CookiesConfig":     { "CookieBehavior": "none" },    // cookies never key media
    "QueryStringsConfig":{ "QueryStringBehavior": "none" } // ignore ?v=, ?utm_ params
  }
}

The exact, annotated procedure — including why an unnormalized Accept string fragments the cache into hundreds of variants and how to collapse it with a CloudFront Function — is covered in CloudFront Cache Policy for Vary: Accept negotiation.

TTL interplay with Cache-Control

CloudFront’s three TTL knobs do not simply set the cache lifetime — they clamp what your origin asks for. The interaction depends on whether the origin sends a Cache-Control: max-age (or Expires) header at all.

Origin sends CloudFront edge TTL is Notes
No Cache-Control / Expires DefaultTTL This is the only case where DefaultTTL applies
max-age=600, and 600 is within [MinTTL, MaxTTL] 600 Origin wins inside the clamp window
max-age=5, MinTTL=60 60 MinTTL raises the floor — object kept longer than origin asked
max-age=99999999, MaxTTL=31536000 31536000 MaxTTL caps an over-long origin value
no-cache / no-store / private Not cached (MinTTL=0) or MinTTL if >0 With MinTTL>0, CloudFront may still cache despite no-cache

Tradeoff: Setting MinTTL above 0 is a footgun for media that occasionally needs a no-cache escape hatch. If MinTTL is 60, an origin response with Cache-Control: no-cache is still cached for 60 seconds — you have overridden your origin’s explicit instruction. Keep MinTTL at 0 or 1 unless you have a deliberate reason, and control freshness from the origin max-age as described in best practices for setting max-age on CDN media assets.

Plotting the clamp on a log axis makes the three regimes obvious: an origin value inside the window passes through untouched, a value below the floor is pulled up to MinTTL, and a value above the ceiling is pulled down to MaxTTL.

MinTTL and MaxTTL clamping an origin max-age A logarithmic time axis from one second to one year. A shaded band marks the cacheable window between MinTTL of 60 seconds and MaxTTL of one year. Three origin Cache-Control values are plotted above the axis: max-age=600 lands unchanged inside the window, max-age=5 is raised up to the 60 second floor, and max-age=99999999 sits beyond the ceiling and is capped at one year. How MinTTL and MaxTTL clamp the origin max-age log time axis; policy shown with MinTTL 60 s, DefaultTTL 86,400 s, MaxTTL 31,536,000 s max-age=600 max-age=5 max-age=99999999 inside the window → unchanged DefaultTTL MinTTL MaxTTL 1 s 5 s 60 s 10 min 1 d 30 d 1 y raised to the MinTTL floor capped at the MaxTTL ceiling DefaultTTL applies only when the origin sends neither Cache-Control nor Expires. A MinTTL above 0 also overrides an origin no-cache — keep it at 0 or 1 for media.

Regional edge caches and Origin Shield

CloudFront is not one cache layer but up to three, and knowing which one answered a request changes how you read a Miss. A viewer hits an edge location (roughly 600 of them). On a miss, most edge locations do not go straight to your origin — they consult a regional edge cache, a smaller set of larger caches that hold objects longer because they see the aggregated traffic of many edges. Only on a miss there does the request reach the origin, or, if you enabled it, an Origin Shield: a single designated region that every regional edge cache funnels through.

For media the tiering matters for two reasons. First, a media catalogue’s rarely-requested tail is exactly the traffic pattern regional edge caches were built for — objects requested a few times per day per continent stay warm regionally even though no single edge holds them. Second, if you run a transcoding origin, Origin Shield is what turns “one encode per regional cache” into “one encode globally”. Without it, a fresh AVIF variant can be generated a dozen times for a dozen regions.

Warning: Origin Shield is billed as a request layer and adds a hop to genuine origin fetches, so it is not free latency-wise. Enable it in the region closest to your origin — for an S3 bucket in eu-west-1, shield in eu-west-1 — so the extra hop is the short one. Shielding a us-east-1 bucket from Frankfurt adds a transatlantic round trip to every miss.

Two behaviours commonly mistaken for bugs follow from this architecture. Regional edge caches are bypassed for dynamic responses and for PUT/POST, so a distribution that mixes API and media traffic will show different miss patterns per behavior. And an object evicted from an edge but still resident regionally produces a Miss from cloudfront in x-cache with a very low latency — the header reports the edge’s view, not the whole hierarchy, which is why latency and x-cache sometimes disagree.

Compression

CloudFront can gzip/brotli-compress origin responses at the edge when EnableAcceptEncodingGzip/Brotli are set in the Cache Policy. Two rules matter for media:

  • Never rely on CloudFront to compress already-compressed media. AVIF, WebP, MP4, and WebM are entropy-coded; edge compression burns CPU for a fraction of a percent. Compression matters for the SVG, JSON, and text assets served alongside media, not the media itself.
  • Enabling Accept-Encoding normalization changes the cache key. When you set the two encoding flags, CloudFront adds a normalized Accept-Encoding dimension to the key (collapsing the dozens of browser encoding strings into gzip, br, or identity). This is safe and desirable — it is the correct way to key on encoding without the fragmentation you would get from whitelisting the raw Accept-Encoding header yourself.

CloudFront Functions vs Lambda@Edge for media

CloudFront offers two edge-compute runtimes. Choosing the wrong one is a common and expensive mistake.

Dimension CloudFront Functions Lambda@Edge
Runtime Constrained JS (ECMAScript 5.1-ish) Node.js / Python, full runtime
Triggers viewer-request, viewer-response viewer-req/res, origin-req/res
Max execution < 1 ms 5 s (viewer), 30 s (origin)
Package size 10 KB 1 MB (viewer), 50 MB (origin)
Network / disk access None Yes (can call S3, fetch)
Cost ~1/6th of Lambda@Edge Higher, billed per ms
Ideal media use Normalize Accept, rewrite paths, header tweaks Convert/generate a derivative, read S3, call an image API

The rule of thumb: use a CloudFront Function on viewer-request to normalize headers (collapse a 200-character Accept into avif/webp/jpeg) so your cache key stays small; use Lambda@Edge on origin-response when you need to produce bytes — for example running sharp to transcode a JPEG into AVIF. That heavier pattern is detailed in Lambda@Edge AVIF conversion on CloudFront.

Warning: A CloudFront Function cannot read the S3 body or make network calls, so it can never transcode an image. Teams that try to do format conversion in a CloudFront Function hit the “no fetch” wall and rewrite in Lambda@Edge — plan for Lambda@Edge from the start if bytes must change.

The choice collapses to a single question — does the edge need to change the bytes, or only the metadata? — and the answer determines the runtime, the trigger, and the cost profile together:

Choosing an edge compute runtime for media A three-branch decision tree. If the edge only reads or rewrites headers, the URL, or the status, use a CloudFront Function on viewer-request. If it must change the response bytes, use Lambda at Edge on origin-response. If neither, use no edge compute and let the Cache Policy serve the stored object. Each outcome lists its constraints. Request hits /images/* what must the edge do? Only read or rewrite headers, URL, status? Change the response bytes themselves? Neither — serve the stored object as-is? CloudFront Function viewer-request · under 1 ms e.g. normalize Accept Lambda@Edge origin-response · up to 30 s e.g. sharp encodes AVIF No edge compute Cache Policy only cheapest — prefer it 10 KB code · no network no access to the body 50 MB package · billed per ms adds latency to every miss no code to maintain no cold starts Rule: normalize at viewer-request, generate at origin-response — a Function can never transcode.

A fourth option deserves a mention because it removes the question entirely: CloudFront Functions on viewer-response can add headers to a cached object without invalidating it, which is the cheap way to fix a missing Cache-Control or add a security header across an entire media tier without touching origin or re-encoding anything. It cannot change the body, but for header-only corrections it is instant and costs almost nothing.

Invalidation vs versioned URLs

There are two ways to ship a new version of a media asset through CloudFront, and only one of them scales.

Invalidation tells CloudFront to purge cached objects matching a path. It is slow (seconds to minutes to propagate across all POPs), rate-limited, and — past the first 1,000 paths per month — billed per path. Wildcard invalidations like /images/* purge everything under the prefix, discarding cache you paid to warm.

Versioned (content-hashed) URLshero.a3f9c2.avif — never need invalidation. A new asset is a new URL, so it is a guaranteed cache miss exactly once, and the old URL harmlessly ages out. Combined with Cache-Control: max-age=31536000, immutable this is the production-standard approach.

# Invalidation: use sparingly, for emergency purges of a hot path only.
# Each path counts toward the monthly free tier of 1000; wildcards purge broadly.
aws cloudfront create-invalidation \
  --distribution-id E1EXAMPLE2ID \
  --paths "/images/hero.avif" "/images/hero.webp"

# Prefer versioned URLs: no invalidation, no propagation delay, no per-path cost.
# The build renames on content hash; the old object simply stops being requested.
#   hero.a3f9c2.avif  ->  hero.b71e04.avif   (deploy = new URL = clean miss)

Tradeoff: Invalidation feels convenient during an incident but trains teams to lean on it. Every wildcard invalidation throws away warm edge cache across hundreds of POPs, spiking origin load and — for on-the-fly transcoding origins — re-running expensive encodes. Version the URLs and reserve invalidation for genuine emergencies.

Step-by-step: a media-ready distribution

Step 1 — Create the Accept-keyed Cache Policy

# Create the Cache Policy from the JSON above. Capture the returned Id — you
# attach it to the behavior by Id, not by name.
aws cloudfront create-cache-policy \
  --cache-policy-config file://cache-policy-accept.json \
  --query 'CachePolicy.Id' --output text

Step 2 — Create an Origin Request Policy that forwards Accept

// origin-request-accept.json — forward Accept (and CloudFront-Viewer-* if using edge logic)
{
  "Name": "media-forward-accept",
  "Comment": "Forwards Accept to the origin so a transcoding origin can negotiate",
  "HeadersConfig": {
    "HeaderBehavior": "whitelist",
    "Headers": { "Quantity": 1, "Items": ["Accept"] }  // origin sees the real Accept
  },
  "CookiesConfig":     { "CookieBehavior": "none" },
  "QueryStringsConfig":{ "QueryStringBehavior": "none" }
}
aws cloudfront create-origin-request-policy \
  --origin-request-policy-config file://origin-request-accept.json \
  --query 'OriginRequestPolicy.Id' --output text

Step 3 — Attach both policies to the /images/* behavior

Fetch the live distribution config, edit the /images/* cache behavior to reference the two policy IDs (and drop any legacy ForwardedValues), then push it back with the current ETag as --if-match:

# Pull the current config and ETag (ETag is required to submit an update).
aws cloudfront get-distribution-config --id E1EXAMPLE2ID > dist.json
ETAG=$(jq -r '.ETag' dist.json)

# ... edit dist.json: set CachePolicyId + OriginRequestPolicyId on the /images/* behavior,
#     remove the deprecated ForwardedValues block from that behavior ...

aws cloudfront update-distribution \
  --id E1EXAMPLE2ID \
  --if-match "$ETAG" \
  --distribution-config "$(jq '.DistributionConfig' dist.json)"

Step 4 — Verify the format negotiation end-to-end

# An AVIF-capable client should get image/avif and, on a warm POP, x-cache: Hit.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'content-type|x-cache|age|x-amz-cf-pop'

# A WebP-only client (simulating Safari 14/15) should get image/webp from a DIFFERENT
# cached object — proving Accept is actually in the cache key.
curl -sI -H 'Accept: image/webp,*/*;q=0.8' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'content-type|x-cache'

If both requests return the same content-type, Accept is not in the cache key — recheck Step 1. Systematic diagnosis of low hit ratios and mismatched variants is covered in debugging CloudFront cache misses for images.

Parameter reference

Parameter Object Meaning
HeadersConfig.HeaderBehavior Cache Policy none, whitelist, or allViewer. Use whitelist with Accept for negotiation; allViewer fragments the cache badly.
MinTTL Cache Policy Freshness floor. Above 0 can override origin no-cache. Keep at 0–1 for media.
DefaultTTL Cache Policy Applied only when the origin sends no Cache-Control/Expires.
MaxTTL Cache Policy Caps an over-long origin max-age.
EnableAcceptEncodingGzip/Brotli Cache Policy Adds normalized encoding to the key; does not compress already-compressed media.
QueryStringBehavior Cache/Origin policy none for media unless you resize via query (?w=800), then whitelist the sizing params only.
CookieBehavior Cache Policy Almost always none for media — a forwarded cookie fragments the cache per user.
PathPattern Cache behavior Only * and ? wildcards, case-sensitive, first match wins in your declared order.
Compress Cache behavior Enables edge gzip/brotli. Leave off for image and video behaviors; it burns CPU on entropy-coded bytes.
OriginShield.Enabled Origin Funnels all regional caches through one region. Set it to the region hosting the origin.
ResponseHeadersPolicyId Cache behavior Adds or overrides response headers (CORS, Cache-Control) without re-fetching or re-encoding.
FunctionAssociations Cache behavior Binds a CloudFront Function to viewer-request or viewer-response. Normalization must use viewer-request.
SmoothStreaming / TrustedKeyGroups Cache behavior Leave streaming off for progressive MP4; use key groups for signed media URLs.

Tradeoffs & failure modes

Failure mode Cause Fix
All browsers get the same format Accept forwarded but not in the cache key Add Accept to the Cache Policy HeadersConfig whitelist
Cache hit ratio collapses to ~0% Raw Accept (200-char string) keyed without normalization Normalize Accept to avif/webp/jpeg in a CloudFront Function
no-cache from origin ignored MinTTL set above 0 Set MinTTL to 0 (or 1) so no-cache is honoured
Cache fragmented per user Cookies forwarded and keyed Set CookieBehavior: none on the media Cache Policy
Cache fragmented per ?utm_* Query strings keyed QueryStringBehavior: none, or whitelist only real sizing params
Format conversion “impossible” at edge Attempted in a CloudFront Function Use Lambda@Edge origin-response for byte transformation
Deploy doesn’t show new image Relying on cached old URL Use content-hashed URLs; reserve invalidation for emergencies
One directory of images is uncached Path pattern casing mismatch (/Images/ vs /images/*) Patterns are case-sensitive — normalize paths or add a second behavior
A newly added behavior never fires It sits below a broader pattern that matches first Reorder: most specific patterns above general ones; * is always last
AVIF re-encoded once per region Origin Shield not enabled on the origin Enable Origin Shield in the origin’s own region so the encode runs once
x-cache says Miss but latency is 20 ms Served by a regional edge cache, not the edge location Read x-cache as the edge’s view only; confirm with CloudWatch CacheHitRate
Response headers wrong on every object Trying to fix them at the origin behind a long TTL Attach a Response Headers Policy — it rewrites without invalidating

Debugging

The three headers that tell you what CloudFront did are x-cache, age, and x-amz-cf-pop:

# x-cache: "Hit from cloudfront" = served from edge; "Miss from cloudfront" = went to origin.
# age: seconds the object has lived in this POP's cache (0 = just fetched).
# x-amz-cf-pop: the edge location (e.g. LHR50-C1) — a cold POP explains a one-off Miss.
curl -sI -H 'Accept: image/avif,image/webp,*/*' \
  https://d111111abcdef8.cloudfront.net/images/hero \
  | grep -iE 'x-cache|age|x-amz-cf-pop|content-type|cache-control'

# Expected on a warm edge:
#   content-type: image/avif
#   x-cache: Hit from cloudfront
#   age: 3421
#   x-amz-cf-pop: LHR50-C1

A Miss on the first request to a given POP is normal — CloudFront’s cache is per-POP, so the first viewer routed to a new edge location warms it. A persistent Miss for the same URL and Accept value points at an over-keyed Cache Policy or an origin that refuses to be cached. Walk the causes in the dedicated debugging guide below.

Measuring the hit ratio properly

curl proves a single object behaves; it says nothing about the distribution as a whole. Two AWS-side signals close that gap.

# 1) CacheHitRate is a per-distribution CloudWatch metric (us-east-1 only, whatever
#    region your distribution "lives" in). It counts edge hits over total requests,
#    so a healthy media behavior should sit well above 90%.
aws cloudwatch get-metric-statistics \
  --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 --region us-east-1

# 2) Real-time logs carry the fields curl cannot show you: which behavior matched,
#    the cache-key result, and the Accept value AFTER the viewer-request Function ran.
#    Enable a real-time log config on the /images/* behavior with these fields:
#      timestamp, sc-status, cs-uri-stem, x-edge-result-type,
#      x-edge-detailed-result-type, cs(Accept), c-country
#    x-edge-result-type = Hit | Miss | RefreshHit | LimitExceeded | Error

x-edge-result-type is the field worth building a dashboard on. A rising Miss share with a flat request count means the key gained a dimension; a rising RefreshHit share means TTLs are short enough that objects keep revalidating, which is a max-age problem rather than a key problem. LimitExceeded in the media behavior almost always means an unbounded resize parameter is generating requests faster than the origin can answer.

Tradeoff: real-time logs are billed per line and, on a busy media tier, that is a lot of lines. Sample at 1–5 % for steady-state monitoring and raise the rate only while investigating; the aggregate ratios stay accurate at low sample rates because media traffic is high-volume.