CloudFront Cache Policy for Vary: Accept negotiation
Your origin sets Vary: Accept, your <picture> element is correct, and yet every visitor on a warm CloudFront edge receives the same image format — the one the first visitor happened to request. This is not a bug in your origin; it is CloudFront working as designed. CloudFront does not honour the origin’s Vary: Accept when building its cache key, so you must instead add Accept to the cache key yourself through a Cache Policy. This guide sits inside AWS CloudFront Cache Behaviors for Media, within the broader CDN & Edge Media Delivery section, and walks the exact policy, the normalization step that keeps it from wrecking your hit ratio, and the curl checks that prove it works.
Why CloudFront ignores origin Vary: Accept
RFC 7234 caches use the Vary response header to remember which request headers a stored response depends on. CloudFront is deliberately not a fully Vary-driven cache. Its cache key is constructed from an explicit, operator-defined set of dimensions: the URL path, plus exactly the headers, cookies, and query strings named in the attached Cache Policy. Vary from the origin is passed through to the browser (so the browser’s own cache behaves correctly), but it plays no part in CloudFront’s edge cache key.
The consequence: if your Cache Policy does not name Accept, then two requests to /images/hero that differ only in Accept collapse to one cache key. The first request populates the object — say, AVIF — and CloudFront serves that AVIF to a Safari 14 client that asked for WebP. The fix is to make Accept a cache-key dimension so the two requests resolve to two different objects.
This mirrors the origin-side reasoning in the cache-control headers guide: Vary: Accept is the correct signal, but each cache layer must be told to act on it in its own dialect. Nginx and Cloudflare read Vary directly; CloudFront needs the header in its Cache Policy.
The diagram below contrasts keying on the raw Accept string (many cold objects) against keying on a normalized token (three warm objects):
Prerequisite checklist
Exact solution
Step 1 — Create the Accept-keyed Cache Policy
// accept-cache-policy.json
// A Cache Policy that makes Accept part of the cache key so AVIF and WebP
// variants of the same URL are stored as separate edge objects.
{
"Name": "images-accept-negotiation",
"Comment": "Key image cache on a normalized Accept token",
"MinTTL": 1, // 1s floor; keep low so origin no-cache is honoured
"DefaultTTL": 86400, // used only if the origin omits Cache-Control
"MaxTTL": 31536000, // 1y ceiling on an over-long origin max-age
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true, // normalize + key on gzip
"EnableAcceptEncodingBrotli": true, // and brotli (safe for the SVG/JSON alongside)
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": {
"Quantity": 1,
"Items": ["Accept"] // THE key line: Accept enters the cache key
}
},
"CookiesConfig": { "CookieBehavior": "none" }, // cookies must never key media
"QueryStringsConfig": { "QueryStringBehavior": "none" } // ignore ?utm_/?v cache-busters
}
}
# Create it and capture the Id — you attach the policy to a behavior by Id.
CACHE_POLICY_ID=$(aws cloudfront create-cache-policy \
--cache-policy-config file://accept-cache-policy.json \
--query 'CachePolicy.Id' --output text)
echo "$CACHE_POLICY_ID"
Warning: HeaderBehavior: "allViewer" looks like a convenient superset, but it keys the cache on every viewer header — User-Agent, Accept-Language, and more — shattering your cache into thousands of variants. Whitelist Accept and nothing else for negotiation.
Step 2 — Normalize Accept with a CloudFront Function
Whitelisting the raw Accept header is correct but dangerous. Chrome sends image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8; Firefox sends a slightly different order; Safari sends something else again; and versions drift over time. Each unique string becomes its own cache object. The result is severe cache fragmentation — the same AVIF stored under dozens of near-identical keys, each with an independent, cold miss.
The fix is to collapse the raw header into one of three stable tokens before it reaches the cache key. A viewer-request CloudFront Function is the right tool: it runs in under a millisecond and rewrites the header in place.
// normalize-accept.js — CloudFront Function (viewer-request event)
// Collapses the raw Accept header into exactly one of: image/avif, image/webp, image/jpeg.
// This runs BEFORE the cache-key is computed, so the key has at most 3 Accept variants.
function handler(event) {
var request = event.request;
var headers = request.headers;
// CloudFront Function header values are lowercased keys; read defensively.
var accept = (headers['accept'] && headers['accept'].value) || '';
var normalized;
if (accept.indexOf('image/avif') !== -1) {
normalized = 'image/avif'; // AVIF-capable: Chrome 85+, Safari 16+, FF 93+
} else if (accept.indexOf('image/webp') !== -1) {
normalized = 'image/webp'; // WebP but not AVIF: Safari 14–15, older Chromium
} else {
normalized = 'image/jpeg'; // neither: legacy fallback tier
}
// Overwrite Accept so the Cache Policy keys on the 3-value normalized token,
// not the 200-character raw string. The origin still receives a valid Accept.
request.headers['accept'] = { value: normalized };
return request;
}
Publish the function and associate it with the /images/* behavior on the viewer-request event:
# Create + publish the function, then associate on viewer-request.
aws cloudfront create-function \
--name normalize-accept \
--function-config Comment="Normalize Accept to avif/webp/jpeg",Runtime="cloudfront-js-2.0" \
--function-code fileb://normalize-accept.js \
--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text
# (publish with `aws cloudfront publish-function`, then reference the ARN in the
# behavior's FunctionAssociations for EventType=viewer-request)
Tradeoff: Normalizing collapses AVIF/WebP negotiation to three tiers, which is exactly what you want for format selection — but if you later need to negotiate on something finer (e.g. AVIF 10-bit vs 8-bit), extend the token set rather than reverting to the raw header. Every distinct token is a distinct cache object, so keep the set as small as the negotiation genuinely requires.
Step 3 — Attach the policy to the image behavior
# Pull the config + ETag, set CachePolicyId on the /images/* behavior, push it back.
aws cloudfront get-distribution-config --id E1EXAMPLE2ID > dist.json
ETAG=$(jq -r '.ETag' dist.json)
# edit dist.json: set the behavior's CachePolicyId to $CACHE_POLICY_ID and add the
# FunctionAssociation for viewer-request; remove any legacy ForwardedValues.
aws cloudfront update-distribution \
--id E1EXAMPLE2ID --if-match "$ETAG" \
--distribution-config "$(jq '.DistributionConfig' dist.json)"
Verification with different Accept headers
The proof that Accept is in the cache key is that two different Accept values return two different Content-Type responses and each maintains its own independent cache state.
# 1) AVIF-capable request. Expect: content-type: image/avif
curl -sI -H 'Accept: image/avif,image/webp,image/*,*/*;q=0.8' \
https://d111111abcdef8.cloudfront.net/images/hero \
| grep -iE 'content-type|x-cache|age'
# 2) WebP-only request (Safari 14/15). Expect: content-type: image/webp,
# and a SEPARATE x-cache lifecycle from request 1.
curl -sI -H 'Accept: image/webp,image/*,*/*;q=0.8' \
https://d111111abcdef8.cloudfront.net/images/hero \
| grep -iE 'content-type|x-cache|age'
# 3) Legacy request, no modern formats. Expect: content-type: image/jpeg
curl -sI -H 'Accept: */*' \
https://d111111abcdef8.cloudfront.net/images/hero \
| grep -iE 'content-type|x-cache'
Run each request twice. The first should show x-cache: Miss from cloudfront; the second, from the same POP, should show Hit from cloudfront with a rising age. If request 1 and request 2 return the same content-type, the Accept header is not keying the cache — recheck Step 1. Deeper hit-ratio forensics live in debugging CloudFront cache misses for images.
Common mistakes
1. Whitelisting the full Accept string → cache fragmentation
Anti-pattern: Adding Accept to the Cache Policy but skipping the normalization function.
Effect: Every browser build sends a slightly different raw Accept string. Each becomes its own cache object, so the same AVIF is stored dozens of times, each entry independently cold. Hit ratio craters and origin load climbs even though “negotiation works” in a single test.
Fix: Normalize Accept to image/avif/image/webp/image/jpeg in a viewer-request CloudFront Function (Step 2) so the key has at most three variants.
2. Expecting origin Vary: Accept to be enough
Anti-pattern: Relying on the origin’s Vary: Accept and never touching the Cache Policy.
Effect: CloudFront passes Vary to the browser but ignores it for its own key. One variant is cached and served to everyone on that POP.
Fix: Add Accept to the Cache Policy HeadersConfig whitelist. Keep sending Vary: Accept from the origin too — it keeps browser and any downstream RFC-compliant caches correct.
3. Using allViewer instead of a whitelist
Anti-pattern: Selecting the managed AllViewer origin-request behavior or allViewer header behavior to “make sure Accept gets through.”
Effect: The cache keys on every viewer header, including User-Agent and Accept-Language, producing near-per-request fragmentation.
Fix: Whitelist exactly Accept in the Cache Policy; if the origin needs more headers, forward them via the Origin Request Policy without keying them.
4. Normalizing in the wrong event
Anti-pattern: Rewriting Accept in a viewer-response or origin-request trigger.
Effect: The cache key is computed at viewer-request time, before origin-request runs, so an origin-request rewrite never affects the key. The raw string still fragments the cache.
Fix: Attach the normalization function on the viewer-request event, which runs before the cache lookup.
Related
- AWS CloudFront Cache Behaviors for Media — the parent guide on path patterns, Cache Policies, TTL clamps, and edge compute
- Lambda@Edge AVIF Conversion on CloudFront — generate the AVIF/WebP bytes this policy caches
- Debugging CloudFront Cache Misses for Images — diagnose the fragmentation this normalization prevents
- Cache-Control Headers for Image and Video Assets — the Vary: Accept origin semantics CloudFront reinterprets in its cache key