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

  1. Variants stored under a predictable key scheme: img/<assetId>/<width>.<ext> for avif, webp and jpg.
  2. A Cloudflare Worker with an R2 binding (or the equivalent S3 fetch) — the code below uses Workers types from @cloudflare/workers-types.
  3. wrangler 3.x to deploy.
  4. 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.

Many Accept headers collapse to three cache keys Accept headers from Chrome, Safari, Firefox and an old Android browser are normalised into three buckets: avif, webp and jpeg. Each bucket is one cache entry per image width. Normalise first, then cache Chrome 128: image/avif,image/webp… Safari 17: image/webp,image/avif… Old Android: image/webp,*/* Email client: */* pickFormat() substring checks key …/800#avif Chrome, Safari 16.4+ key …/800#webp old Android key …/800#jpeg email, crawlers, */* Hundreds of distinct Accept strings in the wild; three cache entries per image width.
The cache sees a three-valued key; the browser still gets the best format it advertised.

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

  • pickFormat uses substring checks, not q-values. In practice no mainstream browser sends image/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/webp first in its header, but order in Accept is not a preference signal — only q-values are — so your own ordering decides.
  • WIDTHS allow-list. Without it, /img/abc/801, /802, /803 each 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 #avif to a synthetic URL gives you the three-way split without depending on how the platform treats Vary.
  • Vary: Accept on 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):

Median bytes per 800-pixel photo by format Median file size at 800 pixels wide: JPEG 96 kilobytes, WebP 68 kilobytes, AVIF 49 kilobytes. AVIF is about half the JPEG size. Median bytes, 800 px user photo, matched quality JPEG 96 KB WebP 68 KB (−29%) AVIF 49 KB (−49%) A feed of 30 thumbnails at 320 px drops from roughly 470 KB to 240 KB when AVIF is served. Encode cost runs the other way: AVIF takes 5–10× longer to encode than JPEG, paid once at upload.
Negotiation makes the saving automatic for every browser that advertises support, with no markup change.

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

Request path through the negotiating Worker A browser request reaches the Worker, which validates the path and width, normalises Accept to a format, checks the edge cache with the synthetic key, and on a miss reads the variant from R2 and stores it in cache before responding with Vary Accept. Hit path is one cache lookup; miss path adds one R2 read browser validate path + width normalise Accept → format cache.match synthetic key hit R2 get avif → webp → jpg miss put + respond cache.put runs in waitUntil, so the browser never waits for the cache write.
Validation comes before normalisation so junk requests never reach the cache or the bucket.

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.