Fastly VCL: normalize the Accept header for AVIF
A browser’s image Accept header is not one value — it is thousands. Chrome, Firefox, Safari, every crawler, and every version bump send a slightly different string, and if you let Fastly key its cache on that string directly, a single image explodes into hundreds of near-duplicate cache objects. This guide is one procedure from Fastly VCL for Image Format Negotiation, part of the broader CDN & Edge Media Delivery section: how to collapse that noisy header into a single normalized token, add exactly that token to the cache key, and end up with three hot cache objects per image — avif, webp, jpeg — that negotiate correctly and never poison each other.
The whole technique lives in two subroutines and one origin header. It is short. What makes it worth documenting precisely is that each of the three moving parts fails in a different, quietly destructive way if you get it slightly wrong, and the failures do not surface as errors — they surface as a bad cache-hit ratio or a broken image on one browser tier weeks later.
Prerequisite checklist
Why normalization, not raw Accept
Here is a real Chrome-for-images Accept header:
image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Firefox sends a different order, Safari a shorter list, and each is version-dependent. The only fact in that entire string that changes which bytes you should serve is whether image/avif appears (serve AVIF), or failing that whether image/webp appears (serve WebP), or neither (serve JPEG). Every other token — apng, svg+xml, the q weights, the ordering — is noise for the purpose of format negotiation.
Normalization is the act of throwing that noise away before the value reaches the cache key. You compute a single token from the header and hash on the token. Because the token has only three possible values, the cache holds at most three objects per URL, and because the token still captures the one meaningful distinction, negotiation stays correct. It is the difference between a cache keyed on an unbounded string and a cache keyed on a three-value enum.
Note the AVIF accuracy point that drives the branch order: AVIF decoding is a Safari 16.0+ feature (Safari 14 and 15 support only WebP), and Chrome advertises both image/avif and image/webp. So the test must check AVIF first, or every AVIF-capable browser gets needlessly down-tiered to WebP.
The exact solution
Step 1 — Normalize Accept in vcl_recv
Add this to your custom VCL’s vcl_recv. Every line that is not obvious carries a comment explaining why it is there.
sub vcl_recv {
#FASTLY recv
# Scope normalization to image routes only. Running it site-wide would add
# a needless X-Fmt header to HTML/API responses and risk hashing them on it.
if (req.url.path ~ "^/img/") {
# AVIF is tested FIRST and this ordering is load-bearing.
# A Chrome/Edge/Firefox request advertises image/avif AND image/webp in
# the same header. If you matched webp first, every AVIF-capable client
# would be normalized to "webp" and never receive the smaller AVIF.
if (req.http.Accept ~ "image/avif") {
set req.http.X-Fmt = "avif";
# WebP is the middle tier: Safari 14/15 and any browser that supports
# WebP but not AVIF lands here.
} elsif (req.http.Accept ~ "image/webp") {
set req.http.X-Fmt = "webp";
# Explicit terminal default. This branch also catches requests with NO
# Accept header at all (many bots, curl without -H). Leaving X-Fmt unset
# here would create a distinct fourth "unset" cache bucket that hashes
# differently from every real client — silent fragmentation.
} else {
set req.http.X-Fmt = "jpeg";
}
}
return(lookup);
}
Step 2 — Add the token to the cache key in vcl_hash
The normalized header changes nothing until it is part of the hash. Fastly’s default key is req.url + host and ignores your custom header, so you append it explicitly. Keep the #FASTLY hash macro — deleting it drops the URL and host from the key entirely.
sub vcl_hash {
# Emits the default hashing of req.url and req.http.host. Do NOT remove it —
# without it the cache key would be the format token alone and every image
# on the site would collide into three objects total.
#FASTLY hash
# Add the normalized token. This is the line that makes avif / webp / jpeg
# of the SAME url three separate stored objects. Omitting it means the first
# format cached for a url is handed to every later client regardless of what
# they can decode — an AVIF served to a Safari 15 user renders as broken.
hash_data(req.http.X-Fmt);
return(hash);
}
Step 3 — Set Vary: Accept at the origin
The token protects Fastly’s cache. Vary: Accept protects everything downstream of Fastly — a corporate proxy, a browser’s own HTTP cache, or a second CDN in front of a multi-CDN setup. Have the origin emit it so those caches also split variants instead of reusing one format across clients.
sub vcl_fetch {
#FASTLY fetch
if (req.url.path ~ "^/img/") {
# Emit Vary: Accept downstream even though Fastly itself keys on X-Fmt.
# This keeps browser and intermediary caches from serving a stored AVIF
# to a WebP-only client. Setting it here guarantees it is present even
# if the origin forgot to add it.
if (beresp.http.Vary !~ "Accept") {
set beresp.http.Vary = "Accept";
}
}
return(deliver);
}
Warning: Vary: Accept at origin is a supplement to the X-Fmt hash, not a replacement for it. If you drop the hash_data(req.http.X-Fmt) call and rely on Vary: Accept alone, Fastly reverts to varying on the full raw Accept string — reintroducing exactly the hundred-object fragmentation you normalized to avoid. Keep both; they defend different caches.
Verification
Request the same URL three times with the three representative Accept headers. For each, the served Content-Type must match the tier, and repeated requests within a tier must share one cache object (second request is a HIT).
# Tier 1 — AVIF-capable (Chrome / Safari 16+). Expect image/avif.
curl -sI -H 'Accept: image/avif,image/webp,image/apng,*/*;q=0.8' \
https://cdn.example.com/img/hero.jpg \
| grep -iE 'content-type|x-served-fmt|x-cache'
# Tier 2 — WebP-only (Safari 14/15). Expect image/webp.
curl -sI -H 'Accept: image/webp,*/*;q=0.8' \
https://cdn.example.com/img/hero.jpg \
| grep -iE 'content-type|x-served-fmt|x-cache'
# Tier 3 — legacy / bot, no next-gen support. Expect image/jpeg.
curl -sI -H 'Accept: */*' \
https://cdn.example.com/img/hero.jpg \
| grep -iE 'content-type|x-served-fmt|x-cache'
Expected result for Tier 1 (assuming the X-Served-Fmt / X-Cache deliver headers from the parent guide are in place):
content-type: image/avif
x-served-fmt: avif
x-cache: HIT
The critical proof of no fragmentation is that two different AVIF-advertising browsers share one object. Send a Firefox-shaped Accept and a Chrome-shaped one and confirm the second is a HIT on the first’s object:
# Prime the avif bucket with a Chrome-shaped Accept (expect x-cache: MISS first time).
curl -sI -H 'Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' \
https://cdn.example.com/img/hero.jpg | grep -i x-cache
# A Firefox-shaped Accept normalizes to the SAME "avif" token, so this MUST be a HIT.
# If it is a MISS, your hash is still keyed on the raw Accept string, not the token.
curl -sI -H 'Accept: image/avif,image/webp,*/*' \
https://cdn.example.com/img/hero.jpg | grep -i x-cache
If that second request reports MISS, normalization is not reaching the hash — recheck that hash_data(req.http.X-Fmt) is present in vcl_hash and that X-Fmt is being set before return(lookup).
Common mistakes
1. Hashing the raw Accept header
Anti-pattern: relying on Vary: Accept alone, or calling hash_data(req.http.Accept) directly.
Effect: the cache key includes the full variable header. Every distinct browser/version/bot Accept string becomes its own cache object. A single popular image can spawn 100+ cold objects, cratering the hit ratio and hammering the origin (or Image Optimizer) with redundant misses. The negotiation is correct but the cache is shredded.
Fix: normalize to X-Fmt in vcl_recv and hash only that token. The raw Accept should never appear in hash_data().
2. Forgetting to add the token to vcl_hash
Anti-pattern: normalizing Accept into X-Fmt in vcl_recv but never calling hash_data(req.http.X-Fmt).
Effect: the token exists but the cache does not know about it, so all formats of a URL collapse into one object. Whichever format is cached first is served to everyone. An AVIF cached from a Chrome visit is then handed to a Safari 15 client that cannot decode it — a broken image for that entire tier. This is classic format cache poisoning.
Fix: add hash_data(req.http.X-Fmt) after #FASTLY hash in vcl_hash. Verify with the cross-tier curl test above: an AVIF and a WebP request for the same URL must produce two different objects (both MISS on first request).
3. Not setting Vary at origin
Anti-pattern: perfecting the Fastly hash but returning no Vary header from origin.
Effect: Fastly’s own cache is correct, but any cache downstream of Fastly — a browser’s HTTP cache after a format changes, a corporate proxy, or a second CDN — has no signal that the response depends on Accept. It may reuse a stored AVIF for a later WebP-only request on the same connection or proxy.
Fix: emit Vary: Accept from the origin (or set it defensively in vcl_fetch, as shown). Keep it alongside — never instead of — the X-Fmt hash.
4. Testing WebP before AVIF
Anti-pattern: ordering the branch if (Accept ~ "image/webp") … elsif (Accept ~ "image/avif").
Effect: because AVIF-capable browsers also advertise WebP, they match the WebP branch first and get normalized to webp. Every modern client silently receives the larger WebP and the AVIF bucket stays empty — you pay for AVIF encoding and never deliver it.
Fix: always test image/avif first, image/webp second, jpeg as the terminal default. Best format first.
Related
- Fastly VCL for Image Format Negotiation — the full lifecycle: vcl_recv, vcl_hash, vcl_fetch, Image Optimizer, shielding, and surrogate-key purging
- CDN & Edge Media Delivery — programmable edge media delivery across Fastly, Cloudflare, and CloudFront
- Cache-Control headers for image and video assets — how Vary: Accept, max-age, and immutable interact at the CDN boundary
- MIME type configuration for modern media servers — registering image/avif and image/webp so the normalized format is labelled correctly