Negotiating AVIF and WebP with Accept Headers

After working through this guide you will be able to pick the smallest image format a client can actually decode by reading its Accept header, return a correct Vary: Accept so no shared cache hands AVIF bytes to a browser that cannot render them, and then undo the cache fragmentation that Vary creates by collapsing Accept into two or three buckets inside the cache key instead.

Format negotiation is the cheapest 30–50% byte reduction available on an image-heavy page, and it needs no markup changes at all: the browser already tells you what it can decode on every image request. It is one axis of the broader image and media delivery problem, and the only one the client advertises directly. The catch is that Accept is a high-cardinality header. Keying a cache on its raw value is functionally equivalent to keying on browser version, and a cache keyed on browser version is not a cache. The whole job is negotiating on the full header while keying on a coarse summary of it.

Key implementation objectives:

  • Parse Accept on the image request path and select AVIF, WebP or JPEG in that order of preference.
  • Emit Vary: Accept so shared caches and proxies you do not control stay correct.
  • Normalize Accept into a small bucket set and key the CDN cache on the bucket, not the raw string.
  • Know when <picture> is the better answer and apply the decision rule rather than defaulting either way.
Normalizing Accept into three buckets Five distinct raw Accept header strings pass through a normalization function and collapse into three cache-key buckets: avif, webp and jpeg. raw Accept values seen in a day cache-key bucket image/avif,image/webp,image/*,*/*;q=0.8 image/avif,image/webp,*/* image/webp,image/apng,image/*,*/* image/webp,*/*;q=0.8 */* normalizeAccept() substring test, preference order avif 2 raw values folded webp 2 raw values folded jpeg everything else Dozens of distinct strings, three stored objects per image variant

Prerequisites and environment setup

You need a working edge transform layer — the setup from Resizing and Reformatting Images at the Edge is the assumed starting point — plus Wrangler 3.60+ and permission to edit cache rules or cache policies on the zone. Format negotiation without a transform layer is possible if you pre-encode every format at build time, but the cache-key work below is identical either way.

Before changing anything, measure what you are dealing with. Log the raw Accept header on image requests for an hour and count distinct values; this number is exactly how many cache objects each image variant will fan out into if you key on the raw header.

npx wrangler tail --format json \
  | jq -r 'select(.event.request.url | test("/i/")) | .event.request.headers.accept' \
  | sort | uniq -c | sort -rn | head -20

A typical consumer site returns something like this — note that the top three values account for most traffic, but the tail keeps growing as browser versions ship:

  41822 image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
  18904 image/avif,image/webp,*/*
   9611 image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
   4180 image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5
    ... 27 more distinct values

Step-by-step procedure

Step 1 — Bucket the Accept header

The bucketing function is deliberately dumb: a substring test in preference order. Do not parse quality values. Browsers do not use q meaningfully on image Accept headers, and a full RFC 9110 content-negotiation parser adds failure modes without changing a single outcome.

export type FormatBucket = "avif" | "webp" | "jpeg";

/**
 * Collapse a high-cardinality Accept header into three stable values.
 * Order matters: AVIF is smallest, WebP is the wide fallback, JPEG is universal.
 */
export function bucketAccept(accept: string | null): FormatBucket {
  const a = (accept ?? "").toLowerCase();
  if (a.includes("image/avif")) return "avif";
  if (a.includes("image/webp")) return "webp";
  return "jpeg";
}

Three properties make this safe. It is total — every input maps to a bucket, including a missing header. It is monotonic — a client that gains AVIF support moves up a bucket and never down. And it is stable across browser releases, so the bucket count stays at three no matter how many new Accept strings appear in your logs.

If you have measured that AVIF decode is slow on a device class you care about, add one narrowing condition rather than removing the bucket entirely — for example demote to webp when the request also carries Sec-CH-UA-Platform: "Android" on low-end hardware you have profiled. Keep such exceptions to a single line; every extra condition is another bucket.

Step 2 — Select the format and set Vary

The Worker uses the bucket for two separate jobs: it drives the encode, and it becomes the cache key. Vary: Accept goes on the response for the benefit of caches downstream of you — corporate proxies, ISP caches, and any intermediary that does not know about your bucketing.

import { bucketAccept } from "./bucket";

const ENCODE: Record<string, { format: string; quality: number }> = {
  avif: { format: "avif", quality: 55 },
  webp: { format: "webp", quality: 78 },
  jpeg: { format: "jpeg", quality: 80 },
};

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const bucket = bucketAccept(request.headers.get("accept"));

    // Cache key = path + bucket. The raw Accept header never enters it.
    const keyUrl = new URL(url.toString());
    keyUrl.searchParams.set("__fmt", bucket);
    const cacheKey = new Request(keyUrl.toString(), { method: "GET" });

    const cache = caches.default;
    const hit = await cache.match(cacheKey);
    if (hit) return hit;

    const { format, quality } = ENCODE[bucket];
    const upstream = await fetch(`${url.origin}/__master${url.pathname}`, {
      cf: { image: { format, quality, width: 840, fit: "scale-down" } },
    });
    if (!upstream.ok) return new Response("not found", { status: 404 });

    const response = new Response(upstream.body, {
      status: 200,
      headers: {
        "content-type": `image/${format}`,
        "cache-control": "public, max-age=31536000, immutable",
        // For caches downstream of us that do not know about __fmt:
        vary: "Accept",
        "x-fmt-bucket": bucket,
      },
    });

    ctx.waitUntil(cache.put(cacheKey, response.clone()));
    return response;
  },
};

The __fmt parameter exists only on the synthetic cache key — it is never in a URL a browser sees, so it cannot be tampered with and cannot leak into your logs as a real query parameter. Deploy and watch the bucket distribution live:

npx wrangler deploy
npx wrangler tail --format pretty | grep x-fmt-bucket

Step 3 — Teach the CDN cache to key on the bucket

The Worker’s own caches.default handles the colo-local cache, but the CDN layer in front of (or above) it needs the same instruction, or a tiered cache will still fragment on the raw header.

On Cloudflare, set the bucket as a request header with a Transform Rule or in the Worker, then include that header — and only that header — in the custom cache key:

{
  "action": "set_cache_settings",
  "expression": "(starts_with(http.request.uri.path, \"/i/\"))",
  "action_parameters": {
    "cache": true,
    "edge_ttl": { "mode": "override_origin", "default": 31536000 },
    "cache_key": {
      "ignore_query_strings_order": true,
      "custom_key": {
        "query_string": { "include": [] },
        "header": { "include": ["x-fmt-bucket"] },
        "cookie": { "include": [] }
      }
    }
  }
}

On AWS CloudFront, do the bucketing in a CloudFront Function on the viewer-request event (cheap, sub-millisecond, no cold start) and allowlist the resulting header in the cache policy:

function handler(event) {
  var accept = (event.request.headers.accept && event.request.headers.accept.value) || "";
  var bucket = accept.indexOf("image/avif") >= 0 ? "avif"
             : accept.indexOf("image/webp") >= 0 ? "webp"
             : "jpeg";
  event.request.headers["x-fmt-bucket"] = { value: bucket };
  return event.request;
}
resource "aws_cloudfront_cache_policy" "image_format" {
  name        = "image-format-bucket"
  min_ttl     = 1
  default_ttl = 31536000
  max_ttl     = 31536000

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_gzip   = false
    enable_accept_encoding_brotli = false

    headers_config {
      header_behavior = "whitelist"
      headers { items = ["X-Fmt-Bucket"] }
    }
    query_strings_config { query_string_behavior = "none" }
    cookies_config { cookie_behavior = "none" }
  }
}

On Fastly, normalize in vcl_recv and hash the normalized header, then strip the origin’s Vary and re-add only what downstream needs:

sub vcl_recv {
  if (req.url.path ~ "^/i/") {
    if (req.http.Accept ~ "image/avif") {
      set req.http.X-Fmt-Bucket = "avif";
    } elseif (req.http.Accept ~ "image/webp") {
      set req.http.X-Fmt-Bucket = "webp";
    } else {
      set req.http.X-Fmt-Bucket = "jpeg";
    }
  }
}

sub vcl_hash {
  set req.hash += req.http.host;
  set req.hash += req.url;
  set req.hash += req.http.X-Fmt-Bucket;
  return(hash);
}

sub vcl_fetch {
  if (req.url.path ~ "^/i/") {
    unset beresp.http.Vary;
    set beresp.http.Vary = "Accept";
  }
}

The unset then set pair is not redundant: origins routinely emit Vary: Accept, Accept-Encoding, User-Agent and that User-Agent term would fragment the shared cache far worse than Accept ever could. See Cache Key & Vary Configuration for the full treatment of that failure mode.

Why Vary: Accept still matters downstream A time sequence in which an AVIF-capable browser fills a shared proxy, then a legacy browser receives the stored AVIF bytes because the response carried no Vary header. Browser A sends image/avif Shared proxy you do not control it Your edge bucketed key GET hero.jpg, Accept: image/avif forwarded, miss 200 image/avif — no Vary proxy stores AVIF under the bare URL Browser B, no AVIF support AVIF bytes it cannot decode Vary: Accept is for caches you do not own your own key uses the bucket; the header keeps everyone else correct

Step 4 — Decide between header negotiation and <picture>

<picture> solves the same problem in markup. The server publishes one URL per format, the browser walks the <source> list in order and takes the first type it supports, and no header influences the response — so no Vary, no bucketing, and no cache fragmentation at all.

<picture>
  <source type="image/avif" srcset="/i/card/9f2ac41b.avif 640w, /i/hero/9f2ac41b.avif 1600w" sizes="60vw">
  <source type="image/webp" srcset="/i/card/9f2ac41b.webp 640w, /i/hero/9f2ac41b.webp 1600w" sizes="60vw">
  <img src="/i/card/9f2ac41b.jpg" srcset="/i/hero/9f2ac41b.jpg 1600w" sizes="60vw"
       width="1600" height="900" alt="Fiber patch panel in an edge cabinet">
</picture>

The cost is markup volume: three source lists per image, multiplied by every width in your ladder, in every template. It also puts format support knowledge in your HTML, which is cached and therefore stale — add a new format and old cached pages keep offering the old list.

The decision rule is short. Use header negotiation when you control the cache key end to end (single CDN, bucketing configured) and you want one URL per image. Use <picture> when your responses pass through caches you do not control, when the HTML is aggressively cached but you still want per-format URLs, or when you need art direction — a genuinely different crop per breakpoint — which no header can express.

Negotiation strategy decision tree A two-level decision tree: if the CDN cannot bucket Accept, use the picture element; otherwise, if crops differ per breakpoint use picture for art direction, else use Accept negotiation with a bucketed key. Which negotiation strategy? CDN can bucket Accept in the key? Use <picture> one URL per format no Vary anywhere no crops differ per breakpoint? yes <picture> for art direction headers cannot crop yes Accept negotiation one URL, bucketed key Vary: Accept downstream no

Verification

Prove that the same URL returns two different formats, that both are cacheable, and that neither is served to the wrong client.

URL=https://img.example.com/i/card/9f2ac41b0d3e77a1.jpg

# AVIF-capable client
curl -sI -H 'Accept: image/avif,image/webp,image/*,*/*;q=0.8' "$URL" \
  | grep -iE 'content-type|content-length|vary|x-fmt-bucket|cf-cache-status'
content-type: image/avif
content-length: 41218
vary: Accept
x-fmt-bucket: avif
cf-cache-status: HIT
# WebP-only client — same URL
curl -sI -H 'Accept: image/webp,image/apng,image/*,*/*;q=0.8' "$URL" \
  | grep -iE 'content-type|content-length|x-fmt-bucket'
content-type: image/webp
content-length: 63104
x-fmt-bucket: webp
# Legacy client
curl -sI -H 'Accept: image/*,*/*;q=0.8' "$URL" | grep -iE 'content-type|x-fmt-bucket'
content-type: image/jpeg
x-fmt-bucket: jpeg

Confirm the bucketing actually collapses the keyspace by sending two different raw AVIF headers and checking the second one is a hit with a non-zero age:

curl -sI -H 'Accept: image/avif,image/webp,*/*' "$URL" | grep -i 'cf-cache-status\|age'
curl -sI -H 'Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' "$URL" \
  | grep -i 'cf-cache-status\|age'

Both should report HIT with the same age climbing — if the second reports MISS, the raw header is still in the key somewhere upstream.

Finally, verify the bytes are a real image and not an error page dressed as one:

curl -s -H 'Accept: image/avif,*/*' "$URL" -o /tmp/probe.avif
file /tmp/probe.avif
identify -format '%wx%h %m %[bit-depth]\n' /tmp/probe.avif
/tmp/probe.avif: ISO Media, AVIF Image
640x360 AVIF 8

Troubleshooting

A proxy caches one format and serves it to everyone

Symptom: a subset of users — usually inside one corporate network or behind one ISP — see broken image placeholders while everyone else is fine. The cause is a shared cache between you and them that stored the AVIF response under the bare URL because the response carried no Vary: Accept. Confirm it by requesting through the offending network with a JPEG-only Accept and checking whether you still receive image/avif:

curl -sI --proxy http://corp-proxy.internal:3128 \
  -H 'Accept: image/*,*/*;q=0.8' "$URL" | grep -i 'content-type\|via\|age'

A Via: header plus the wrong content-type is the fingerprint. The fix is to emit Vary: Accept — and to check nothing downstream is stripping it, which some optimizing proxies do. If you cannot get Vary honored, switch that route to <picture>, where the URL itself differs per format and no cache can confuse them.

Vary: Accept destroys the hit ratio

Symptom: hit ratio drops sharply right after negotiation ships, cached object count balloons, and transform CPU stays high long after warming. This is Vary being honored by your own CDN on the raw header. Diagnose by comparing hits between two distinct AVIF Accept strings, exactly as in the verification section — if both miss, they are separate objects.

The fix is Step 3: put the bucket in the cache key and keep Vary: Accept only as an outbound signal — the same allowlist-the-minimum discipline described in Customizing Cache Keys to Improve Hit Ratio. On Cloudflare, the platform ignores most Vary values for cacheable objects but a tiered upper tier may not, so set the custom cache key explicitly rather than relying on default behavior. On CloudFront, Vary on a header not in the cache policy is simply not honored, which is why the CloudFront Function approach works — the bucket is a real keyed header, and Accept never is.

AVIF encode cost at the edge

Symptom: p95 latency on image misses is hundreds of milliseconds, transform concurrency limits trip during traffic spikes, and the cost line for the transform product grows faster than traffic. AVIF encoding is genuinely expensive — an order of magnitude more CPU than WebP at comparable perceptual quality, and the relationship between effort/speed settings and encode time is steep.

Three levers, in order of effect. Lower the AVIF effort setting (IMGPROXY_AVIF_SPEED=7, or sharp().avif({ effort: 2 })) — encode time drops sharply for a small file-size penalty. Warm the variants that appear on your top pages so misses happen off the critical path. And cap the AVIF bucket to widths where it actually pays: below roughly 300 px the absolute byte saving over WebP is often under 5 KB, which does not justify the encode.

const useAvif = bucket === "avif" && width >= 320;
const format = useAvif ? "avif" : bucket === "jpeg" ? "jpeg" : "webp";

A browser advertises a format it renders slowly

Symptom: LCP regresses on a specific device class even though transferred bytes fell. Some clients advertise AVIF support while decoding it noticeably slower than JPEG, particularly older mobile SoCs on large images — the network saving is real but the decode cost eats it.

Diagnose by segmenting your field metrics by format bucket and device class rather than looking at aggregate LCP; if the AVIF segment is slower despite smaller payloads, decode is the bottleneck. The remedy is a narrow demotion rule keyed on something stable (platform hint, or a device class you already compute for the cache key), not a global rollback. Keep the number of buckets at three or four — every device-class split multiplies the keyspace, which is the same arithmetic problem you just solved.

Frequently Asked Questions

Do I need Vary: Accept if my CDN already keys on a normalized bucket? Yes, for anything that leaves your CDN. Your key protects your cache; Vary protects every cache between your edge and the browser, including corporate proxies and ISP caches that will otherwise store one format under the bare URL and hand it to clients that cannot decode it. The two mechanisms are complementary, not alternatives.

Why not parse the q values in Accept properly? Because browsers do not use them to express real preferences on image requests — the values are effectively boilerplate, and the ordering already encodes the preference you care about. A full negotiation parser adds code paths that can disagree between your edge and your origin without ever producing a different answer than a three-line substring test.

Is AVIF always the right choice when the client supports it? No. It wins clearly on photographic content at medium and large sizes, where the byte saving over WebP is 20–30%. On small images, on flat graphics with few colors, and on anything where encode latency lands on the critical path, WebP is frequently the better overall trade. Set a width floor for the AVIF bucket and measure the outcome rather than assuming.

How many format buckets should I have? Three — avif, webp, jpeg — covers the entire installed base. A fourth only makes sense if you have measured a real problem it solves, such as a device class that decodes AVIF slowly, and even then prefer narrowing the existing bucket over adding a new one. Every bucket multiplies the stored object count for every width of every image.

What should the fallback be for images with transparency? PNG, not JPEG. JPEG has no alpha channel, so a transparent logo falling through to the JPEG bucket gets composited onto a solid background and looks broken. Detect alpha on the source and switch the bottom bucket to PNG for those assets; AVIF and WebP both handle transparency natively, so only the fallback needs the special case.

Back to Image & Media Delivery Optimization