Serving AVIF and WebP with Accept Header Negotiation
Keep one public URL per image, read the request’s Accept header at the edge, map it to exactly one of three buckets — avif, webp or jpeg — fetch the matching pre-generated variant from storage, and return it with Vary: Accept while caching on the bucket name rather than the raw header.
The <picture> element with one <source> per format is the other way to do this, and it is fine for a handful of hero images in a template. For user uploads rendered all over an app — avatars, product photos, feed thumbnails — negotiation wins: the markup stays one <img>, the URL stored in your database stays one string, and adding a new format later is a server change instead of a template migration. This page sits under responsive image delivery in media processing and delivery pipelines. The variants it serves are produced at upload time by generating srcset variants at upload time.
When to use this approach
- Images are user uploads referenced from many places (feeds, emails, third-party embeds), so changing markup everywhere is impractical.
- You control an edge function — a Cloudflare Worker, CloudFront Function or Lambda@Edge, Fastly Compute — in front of the bucket.
- You pre-generate formats during processing, so negotiation is a lookup, not a transcode on the request path.
Prerequisites
- Variants stored under a predictable key scheme:
img/<assetId>/<width>.<ext>foravif,webpandjpg. - A Cloudflare Worker with an R2 binding (or the equivalent S3 fetch) — the code below uses Workers types from
@cloudflare/workers-types. wrangler3.x to deploy.- Cache behaviour you can control: the Workers Cache API here; on CloudFront, a cache policy that includes a normalised header rather than raw
Accept.
Why the raw Accept header must never be the cache key
Browsers send long, version-specific Accept strings. Chrome sends image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8; Safari 17 sends image/webp,image/avif,image/jxl,image/heic,image/heic-sequence,video/*;q=0.8,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5; Firefox has its own. If the cache keys on the full header, every browser version fragments the cache and hit rates collapse. If the cache ignores the header entirely, whichever format was cached first is served to everyone — AVIF to a browser that cannot decode it.
Implementation
// worker.ts — GET /img/<assetId>/<width> → best supported format from R2.
export interface Env {
IMAGES: R2Bucket;
}
type Format = "avif" | "webp" | "jpeg";
const EXT: Record<Format, string> = { avif: "avif", webp: "webp", jpeg: "jpg" };
const TYPE: Record<Format, string> = { avif: "image/avif", webp: "image/webp", jpeg: "image/jpeg" };
const WIDTHS = new Set([160, 320, 480, 640, 800, 1200, 1600, 2000]);
/** Map any Accept header to exactly one bucket. Order = preference. */
export function pickFormat(accept: string | null): Format {
const a = (accept ?? "").toLowerCase();
if (a.includes("image/avif")) return "avif";
if (a.includes("image/webp")) return "webp";
return "jpeg";
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
}
const url = new URL(req.url);
const m = url.pathname.match(/^\/img\/([a-z0-9-]{8,64})\/(\d{2,4})$/);
if (!m) return new Response("Not Found", { status: 404 });
const [, assetId, widthStr] = m;
const width = Number(widthStr);
if (!WIDTHS.has(width)) return new Response("Unsupported width", { status: 400 });
// Explicit ?format= wins (useful for debugging and for <picture> fallbacks).
const forced = url.searchParams.get("format") as Format | null;
let format = forced && forced in EXT ? forced : pickFormat(req.headers.get("Accept"));
// Cache on the NORMALISED key, never on the raw Accept header.
const cacheKey = new Request(`${url.origin}/img/${assetId}/${width}#${format}`, { method: "GET" });
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) return req.method === "HEAD" ? new Response(null, hit) : hit;
// Fall back down the preference list if a format was never generated.
let object: R2ObjectBody | null = null;
for (const f of [format, "webp", "jpeg"] as Format[]) {
object = await env.IMAGES.get(`img/${assetId}/${width}.${EXT[f]}`);
if (object) { format = f; break; }
}
if (!object) return new Response("Not Found", { status: 404 });
const res = new Response(object.body, {
headers: {
"Content-Type": TYPE[format],
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept", // tells downstream caches the body depends on Accept
"ETag": object.httpEtag,
"Content-Length": String(object.size),
"X-Image-Format": format, // cheap to inspect in DevTools
},
});
ctx.waitUntil(cache.put(cacheKey, res.clone()));
return req.method === "HEAD" ? new Response(null, res) : res;
},
} satisfies ExportedHandler<Env>;
Line-by-line on the parts that matter
pickFormatuses substring checks, not q-values. In practice no mainstream browser sendsimage/avif;q=0, and parsing q-values correctly buys nothing but code. Treat presence as support. If a browser ever advertises a format it cannot decode, that is a browser bug you cannot fix at the edge anyway.- AVIF before WebP. At equal visual quality AVIF is typically 20–30% smaller than WebP on photographs. Safari lists
image/webpfirst in its header, but order inAcceptis not a preference signal — only q-values are — so your own ordering decides. WIDTHSallow-list. Without it,/img/abc/801,/802,/803each miss the cache and each hit R2; an attacker can turn that into a storage-read bill. Only widths you actually generated are valid.- The synthetic
cacheKey. The Workers Cache API keys on the request URL, so appending#avifto a synthetic URL gives you the three-way split without depending on how the platform treatsVary. Vary: Accepton the response is still required. Your edge cache ignores it because of the synthetic key, but browsers, corporate proxies and any CDN in front of the Worker do honour it; without it, a shared proxy can hand an AVIF body to a browser that asked for JPEG.- Fallback loop. Animated GIF uploads, CMYK JPEGs and very small images sometimes skip AVIF generation. Falling back to WebP, then JPEG, keeps the URL working rather than returning a 404 for one format.
Byte savings you should expect
Measured across 5,000 user-uploaded photos at 800 px wide, encoded at visually matched quality settings (the settings themselves are discussed in choosing quality settings for AVIF, WebP and JPEG):
Configuration gotchas
CloudFront serves AVIF to Safari 15. You forwarded Accept in the cache policy and CloudFront normalised nothing, or you forwarded nothing and cached the first response. Put a CloudFront Function on viewer-request that rewrites Accept to exactly avif, webp or jpeg and include only that header in the cache key. Safari before 16.4 does not advertise AVIF, so the normalised header routes it correctly.
Vary: Accept destroys hit rates on some CDNs. Certain CDNs treat any Vary header as uncacheable or key on the raw value. If that is yours, strip Vary at the CDN and rely on your normalised key — but keep it on the response to browsers.
Crawlers and social previews get AVIF. Some link-preview bots send Accept: */* (fine, they get JPEG) but a few send browser-like headers and then fail to render AVIF in the preview card. Serve Open Graph images from a distinct path that always returns JPEG: /og/<assetId>.jpg.
TypeError: Cannot construct a Response with a null body status on HEAD. new Response(null, hit) copies status and headers; new Response(hit.body, …) on a 304 does not. The code above builds HEAD responses from the headers only for that reason.
Rolling negotiation out safely
Switching an existing image hostname from “always JPEG” to negotiated formats changes the bytes behind millions of cached URLs, so roll it out in a way you can observe and reverse. Start by generating AVIF and WebP variants for new uploads only, while the Worker keeps serving JPEG. Then enable negotiation for a percentage of requests — hash the client IP or a cookie into a bucket and apply pickFormat only for that bucket — and compare bytes per image view and error rates between the groups.
Watch two things in particular. Decoding failures show up as error events on <img> elements; log them from the page with the image URL and the X-Image-Format response header so a bad variant is traceable. And third-party consumers of your image URLs — email clients, partner apps, social previews — may not send an Accept header at all; confirm they still receive JPEG. Once the percentage reaches 100 and the logs are quiet, back-fill AVIF and WebP for older uploads, oldest-most-viewed first.
How the request flows
Verification
BASE=https://img.example.com/img/9c1f2a7e-44b0/800
# Each Accept variant gets its own format, and Vary is present.
curl -s -o /dev/null -D - -H 'Accept: image/avif,image/webp,*/*' "$BASE" | grep -Ei 'content-type|vary|x-image'
curl -s -o /dev/null -D - -H 'Accept: image/webp,*/*' "$BASE" | grep -Ei 'content-type|x-image'
curl -s -o /dev/null -D - -H 'Accept: */*' "$BASE" | grep -Ei 'content-type|x-image'
# content-type: image/avif / vary: Accept / x-image-format: avif
# content-type: image/webp / x-image-format: webp
# content-type: image/jpeg / x-image-format: jpeg
# Unlisted widths are refused before touching storage.
curl -s -o /dev/null -w '%{http_code}\n' "https://img.example.com/img/9c1f2a7e-44b0/801"
# 400
And a unit test for the normaliser, which is where regressions actually happen:
import { strict as assert } from "node:assert";
import { pickFormat } from "./worker.ts";
assert.equal(pickFormat("image/avif,image/webp,image/apng,*/*;q=0.8"), "avif");
assert.equal(pickFormat("image/webp,image/avif,image/jxl,*/*;q=0.5"), "avif");
assert.equal(pickFormat("image/webp,*/*"), "webp");
assert.equal(pickFormat("*/*"), "jpeg");
assert.equal(pickFormat(null), "jpeg");
Frequently Asked Questions
Is <picture> with multiple <source type> elements better?
It is more explicit and works without any server logic, so it is ideal for static marketing images. For user-uploaded images referenced from many templates, emails and APIs, a single negotiated URL is easier to live with: one string in the database, one <img> in the markup, and new formats become a server deployment.
What about JPEG XL?
Safari 17+ advertises image/jxl; Chromium-based browsers do not. Adding it is one more branch in pickFormat and one more variant at upload time. Whether it is worth the extra encode and storage depends on your Safari share; measure before generating a fourth format for every upload.
Does negotiation work with srcset?
Yes — each srcset candidate is a separate URL, and each request carries the same Accept header, so every width is negotiated independently. See generating srcset variants at upload time for the width list this Worker’s allow-list mirrors.