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.
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.

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.

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.

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

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.