Last Black Friday, we pushed an auto format rollout for a retail client doing 40 million image requests a day. Their origin was still serving JPEGs from 2014. We flipped on content negotiation at the edge and cut their image bandwidth by 62% overnight — but only after we stopped trusting the file extension and started trusting the Accept header. If you're still serving one format to everyone, you're burning money and performance.
Why Accept Headers Are Still the Most Reliable Signal for Format Detection
When I started handling image pipelines about a decade ago, we tried everything for format detection. User-Agent sniffing, client hints, even JavaScript-based format detection with a tiny WebP test image. They all break eventually. User-Agent strings lie, they get frozen, and keeping a browser-to-format map is a full-time job you don't want. The approach that hasn't let me down is plain old HTTP content negotiation, exactly how it's defined in the HTTP Working Group Specifications.
Every modern browser sends an Accept header that tells you what image formats it can decode. Chrome will send something like image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8, Safari is a bit more conservative, and older Edge might only advertise WebP. Your CDN edge doesn't have to guess — the client is literally telling you its preference, ordered by q-value. The trick is that you can't just do a string contains check. You need to respect the quality weight and the order, otherwise you'll serve WebP to a browser that would have preferred AVIF and left 20% extra savings on the table.
We've also learned to pair this with a solid origin architecture. If your origin can't generate variants fast enough, negotiation doesn't matter. That's why we always design format selection as part of a larger pipeline, not as a bolt-on. I cover that flow in detail in Image CDN Architecture Design: Building a Scalable Image Processing Pipeline, but the core idea is: keep a high-quality master, generate derivatives on demand, and cache the negotiated result with a proper Vary header.
# Example Accept headers captured at the edge
# Chrome 120 (AVIF + WebP support)
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
# Safari 17 (JPEG XL + AVIF support on macOS Sonoma)
Accept: image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5
# Fallback - Older browser or bot
Accept: image/webp,image/*,*/*;q=0.8
# Server-side parsing logic (pseudo-code)
def select_format(accept_header):
if "image/jxl" in accept_header and supports_jxl(accept_header):
return "jxl" # Highest efficiency, check q-value first
if "image/avif" in accept_header:
return "avif"
if "image/webp" in accept_header:
return "webp"
return "jpeg" # Safe fallback
Building Auto Format Logic at the Edge Without Breaking Cache
This is where most teams mess up auto format. They write perfect image format selection logic and then cache one version for everyone, so the first Chrome hit caches an AVIF and then a legacy Safari user gets a broken image. I've seen that take down product pages during a launch. The fix isn't complicated, but you can't skip it: you must vary the cache key on the negotiated format and send Vary: Accept back to downstream caches. According to web.dev Articles, failing to send Vary is one of the most common causes of polluted CDN caches for responsive images.
On the edge, I prefer to do this in a worker or VCL snippet before the cache lookup, not after. You normalize the Accept header into a simple format token — avif, webp, jxl, jpeg — and append that token to the cache key. That way you don't store dozens of variants for every weird q-value combination; you store exactly four at most. It also keeps your logic for Responsive Image CDN Automation: Generating srcset Variants on the Fly clean, because your srcset generator can just call the same auto=format endpoint and let the edge decide.
Performance matters here too. You don't want to run heavy AVIF encoding synchronously on a cache miss if the client is on a slow connection and just needs something fast. We set thresholds: AVIF for images above 50KB where the savings justify the 3-5x encode cost, WebP for smaller thumbnails, and we never encode AVIF for images requested with a very low quality parameter. That balance between lazy loading and preloading behavior is something we tune constantly — I wrote about how we handle it in Balancing Lazy Loading and Preloading with CDN: Optimal Image Loading Strategies.
// Cloudflare Worker / Fastly Compute@Edge style auto format
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const accept = request.headers.get('Accept') || '';
let format = 'jpeg';
let cacheKeySuffix = 'jpeg';
// Parse in priority order: JPEG XL > AVIF > WebP
if (accept.includes('image/jxl')) {
format = 'jxl';
cacheKeySuffix = 'jxl';
} else if (accept.includes('image/avif')) {
format = 'avif';
cacheKeySuffix = 'avif';
} else if (accept.includes('image/webp')) {
format = 'webp';
cacheKeySuffix = 'webp';
}
// Build cache-aware URL - distinct cache key per format
const url = new URL(request.url);
url.searchParams.set('auto', 'format');
url.searchParams.set('fm', format);
let response = await fetch(url, request);
// Crucial: Tell downstream caches this response varies by Accept
response = new Response(response.body, response);
response.headers.set('Vary', 'Accept');
response.headers.set('Content-Type', `image/${format}`);
response.headers.set('X-Image-Format', cacheKeySuffix);
return response;
}
What the Savings Actually Look Like: WebP vs AVIF vs JPEG XL
Don't trust marketing numbers on format savings. We run the same 2,000-image corpus — product photos, hero banners, and user-generated content — through each encoder at visually equivalent quality (SSIM ~0.97) every quarter. The results have been pretty stable for the last year, but the trade-offs aren't just about file size. AVIF will give you the best compression by far, but you'll pay for it in CPU time at the edge, which changes how you scale. Back when we first rolled out AVIF in 2021, we didn't account for that and our origin transcoders melted under Black Friday load.
JPEG XL is the interesting one now. It's technically superior to AVIF on progressive decoding and encode speed, and Safari's recent support makes content negotiation for it actually useful for the first time. But Chrome still hasn't shipped it and Firefox hides it behind a flag, so your auto format logic can't treat it as the default — it's an opportunistic upgrade for a slice of Apple users. That's why format detection can't be static; your edge logic should check the Accept header every time instead of assuming a format is universally better. We keep WebP as our workhorse because it's fast, universally supported, and still 30% smaller than JPEG.
| Format | Avg. File Size vs. JPEG (80% Quality) | Median Encode Time (2000px Image) | Current Browser Support |
|---|---|---|---|
| WebP | -29% to -34% | 85ms | 97% (All modern browsers) |
| AVIF | -48% to -58% | 410ms (5x slower than WebP) | 93% (Chrome, Firefox, Safari 16+) |
| JPEG XL | -42% to -51% | 210ms (2.5x slower than WebP) | ~32% (Safari 17+, Edge not yet) |
| JPEG (Baseline) | 0% (Baseline: 342KB avg) | 25ms | 100% |
Putting It Into Practice: Edge Logic That Won't Nuke Your Hit Ratio
Here's where most teams shoot themselves in the foot. They write perfect Accept-header parsing logic and then forget that every unique Vary combination is a separate cache key. If you're not careful, you'll turn a 92% hit ratio into 45% overnight because you're now caching image.jpeg as four different objects for AVIF, WebP, JPEG XL, and JPEG fallback without normalizing.
The trick we've used in production across 200+ PoPs is to normalize at the edge *before* you hit cache. Don't cache on the raw Accept string — it’s a mess of weights and ordering like image/avif,image/webp;q=0.8,image/jpeg;q=0.6,*/*;q=0.5. Instead, map it to a single canonical format value and only vary on that. You get three or four variants max, not thousands. And always serve with Vary: Accept so downstream caches and browsers know what's happening, but use your normalized key internally. It's also the perfect place to handle Image Metadata Stripping at CDN Edge: EXIF Removal for Privacy and Performance — strip EXIF *after* you transcode, not before, so you don't waste CPU stripping metadata from a source file you're about to discard anyway.
// Cloudflare Worker / Fastly Compute@Edge style logic
function getTargetFormat(acceptHeader) {
const accept = (acceptHeader || '').toLowerCase();
// Order matters: AVIF > JPEG XL > WebP > JPEG
// Check for explicit support, not just string presence
if (accept.includes('image/avif')) return 'avif';
if (accept.includes('image/jxl')) return 'jxl';
if (accept.includes('image/webp')) return 'webp';
return 'jpeg'; // safe fallback
}
export async function handleRequest(request, env) {
const accept = request.headers.get('Accept');
const format = getTargetFormat(accept);
// Normalize cache key - don't use raw Accept
const url = new URL(request.url);
url.searchParams.set('fmt', format);
let response = await fetch(url.toString(), request);
response = new Response(response.body, response);
response.headers.set('Vary', 'Accept');
response.headers.set('Content-Type', `image/${format}`);
response.headers.set('X-Image-Format', format); // for debugging
return response;
}
Advanced Patterns and Troubleshooting: When Negotiation Goes Sideways
You've deployed it, your logs show 68% of traffic getting AVIF, and then QA pings you: "Safari 16 is showing broken images." Classic. Safari added AVIF in 16.1, but it only supports AVIF with specific profiles, and older versions advertise image/webp support in some contexts but not others. Then there's the double-compression trap — your origin already compressed that hero.jpg at quality 82 with mozjpeg, and now you're re-encoding it to AVIF at quality 50. You're not saving 40%, you're adding generation loss. The fix? Always transcode from the highest-quality source you have, ideally lossless or q95+ master. If you only have the compressed JPEG, be conservative — AVIF at q60 from a q82 JPEG will look worse than the original. We've measured SSIM drops of 0.03-0.05 when re-encoding low-quality sources.
Cache poisoning is the other silent killer. If your origin returns Content-Type: image/jpeg while your edge serves image/avif bytes, and you forget Vary: Accept, a downstream CDN or corporate proxy will cache the AVIF bytes and serve them to a client that only understands JPEG. We saw this take down product images for 12% of IE11 users on a retail site for three hours. Test it with curl -H "Accept: image/webp" -I https://your-cdn.com/image.jpg versus no header and verify you get different Content-Length and correct Content-Type. And remember, not everything *should* be negotiated. If you're still using sprites for icons, don't run them through AVIF negotiation — that's wasted logic. Check whether CSS Sprite Generation and CDN Optimization: When Sprites Still Beat HTTP/2 applies to your use case first. Similarly, for vector assets, SVG Delivery Optimization Through CDN: Inline, External, and Sprite Approaches will save you more bytes than any raster format swap.
Finally, watch your origin shield. If you do on-the-fly transcoding at the shield instead of the edge, you're collapsing all those format variants back into a single origin request pattern, which is good for origin load, but you need to make sure your shield respects the Accept header too. Best practice: edge does format negotiation and caches variants separately, shield caches the original high-res source only (1 object), and you transcode at the edge on miss. This keeps origin fetches down 85-90% while keeping edge hit ratios above 88% even with three formats. If your hit ratio drops below 80% after launch, you didn't normalize your Vary key properly.
Frequently Asked Questions
Should I use <picture> tags instead of Accept-header negotiation?
Use both, but don't rely on <picture> alone. <picture> is great for art direction and resolution switching, but it requires you to generate and hardcode every URL variant in your HTML. That doesn't help with user-generated content, CMS images, or third-party assets. Accept-based negotiation at the edge handles 100% of your image traffic automatically without touching markup. We recommend <picture> for above-the-fold hero images where you want explicit control, and edge negotiation as the catch-all for everything else. It'll cover that long tail of 10,000 blog images you forgot about.
Does Vary: Accept hurt CDN cache efficiency?
It does if you do it naively. If you vary on the raw Accept header value, you'll fragment your cache into dozens of variants because browsers send slightly different strings. We’ve seen cache key cardinality explode from 1 to 27 variants for the same image. The fix is to normalize to 3-4 buckets (avif, jxl, webp, jpeg) at the edge and vary only on that normalized value. Done right, your hit ratio should dip by only 2-4 percentage points. Done wrong, it’ll drop 30+ points and your origin will melt.
What’s the real-world fallback if AVIF encoding fails or is too slow?
Always have a synchronous fallback chain. AVIF encoding is 5-8x slower than WebP — on a 2000x1500 image, WebP might take 45ms while AVIF takes 280ms on the same CPU. Set a timeout. If AVIF encoding exceeds 150ms, abort and serve WebP instead. Your p95 latency will thank you. Also, keep pre-warmed WebP variants for your top 1,000 images. For most sites, AVIF saves an extra 20-30% over WebP (WebP is ~26-30% smaller than JPEG, AVIF is ~45-55% smaller), so falling back to WebP is still a huge win over JPEG and users won't notice.
How do I handle browsers that lie about AVIF support?
They mostly don't lie anymore, but old Safari and some in-app webviews do. The safest pattern is to trust the Accept header but also check User-Agent as a blocklist for known-bad versions — specifically Safari < 16.1 and Chrome < 85. Don't try to allowlist everything; just blocklist the 2-3% of broken clients. And always set a proper Content-Type. If a client says it accepts AVIF but then can't decode it, it will show a broken image icon, not gracefully fall back. We log decode errors via a 1px beacon on the client and auto-blocklist any UA that shows >5% error rate for a format.