DEV Community

Cover image for Serve AVIF or WebP by Accept header, and the Vary header trap
Sahadat Husain
Sahadat Husain

Posted on

Serve AVIF or WebP by Accept header, and the Vary header trap

Read the request's Accept header, pick AVIF, then WebP, then JPEG, and always send Vary: Accept with the response. Without Vary, a shared cache stores the first variant it sees and hands it to everyone. In my test, a client that sent Accept: */* got image/avif from the cache.

What do browsers send in Accept for images?

MDN keeps a list of default Accept values. For <img> requests it gives:

Browser Accept for images
Chrome and Edge 121+ image/avif,image/webp,image/apng,image/*,*/*;q=0.8
Firefox 158+ image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5
Safari since macOS Big Sur image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5

Notice Safari. caniuse lists full AVIF support from Safari 16.4, yet the Accept value MDN lists for Safari names WebP and not AVIF. A server that negotiates by Accept sends Safari WebP. That is correct behaviour, just not the smallest file.

What does the server look like?

A plain node:http server with sharp. It encodes three variants of one 1920 px photo at start-up and picks one per request. It only picks a format the client names explicitly; image/* and */* say nothing about AVIF.

import http from 'node:http';
import sharp from 'sharp';

const base = sharp('camera.jpeg').resize({ width: 1920 });
const variants = {
  avif: { type: 'image/avif', body: await base.clone().avif({ quality: 50 }).toBuffer() },
  webp: { type: 'image/webp', body: await base.clone().webp({ quality: 80 }).toBuffer() },
  jpeg: { type: 'image/jpeg', body: await base.clone().jpeg({ quality: 80, mozjpeg: true }).toBuffer() },
};

export function pick(accept = '') {
  const types = accept.split(',').map((part) => {
    const [type, ...params] = part.trim().split(';');
    const q = params.find((p) => p.trim().startsWith('q='));
    return { type: type.trim(), q: q ? Number(q.split('=')[1]) : 1 };
  });
  const ok = (t) => types.some((x) => x.type === t && x.q > 0);
  if (ok('image/avif')) return 'avif';
  if (ok('image/webp')) return 'webp';
  return 'jpeg';
}

const VARY = process.env.VARY !== 'off';
http.createServer((req, res) => {
  const v = variants[pick(req.headers.accept)];
  const headers = {
    'Content-Type': v.type,
    'Content-Length': v.body.length,
    'Cache-Control': 'public, max-age=31536000',
  };
  if (VARY) headers.Vary = 'Accept';
  res.writeHead(200, headers).end(v.body);
}).listen(process.env.PORT ?? 8080, () => {
  for (const [k, v] of Object.entries(variants)) console.log(k, v.body.length, 'bytes');
});
Enter fullscreen mode Exit fullscreen mode
avif 66799 bytes
webp 106876 bytes
jpeg 153394 bytes
Enter fullscreen mode Exit fullscreen mode

Then curl with each browser's value:

for a in "image/avif,image/webp,image/apng,image/*,*/*;q=0.8" \
         "image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5" \
         "image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5" \
         "*/*"; do
  echo "== $a"
  curl -s -o /dev/null -D - -H "Accept: $a" http://localhost:8080/photo | grep -iE "content-type|content-length|vary"
done
Enter fullscreen mode Exit fullscreen mode
== image/avif,image/webp,image/apng,image/*,*/*;q=0.8
Content-Type: image/avif
Content-Length: 66799
Vary: Accept
== image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5
Content-Type: image/avif
Content-Length: 66799
Vary: Accept
== image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5
Content-Type: image/webp
Content-Length: 106876
Vary: Accept
== */*
Content-Type: image/jpeg
Content-Length: 153394
Vary: Accept
Enter fullscreen mode Exit fullscreen mode
Client Gets Bytes vs JPEG
Chrome, Firefox AVIF 66,799 -56%
Safari WebP 106,876 -30%
*/* JPEG 153,394

What goes wrong without Vary?

A shared cache (a CDN, a reverse proxy) keys responses by URL. RFC 9111, section 4.1 says that when a stored response carries Vary, the cache may reuse it only for requests whose named headers match. No Vary, no check.

I wrote a toy cache that follows that rule and sent four clients through it, in order:

const store = new Map();
async function get(url, accept) {
  for (const [key, entry] of store) {
    if (!key.startsWith(url)) continue;
    if (entry.vary.every((h) => entry.req[h] === accept)) return { ...entry, hit: true };
  }
  const res = await fetch(url, { headers: { accept } });
  const vary = (res.headers.get('vary') ?? '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
  const entry = { type: res.headers.get('content-type'), bytes: (await res.arrayBuffer()).byteLength,
    vary: vary.map(() => 'accept'), req: { accept } };
  store.set(`${url} ${store.size}`, entry);
  return { ...entry, hit: false };
}
Enter fullscreen mode Exit fullscreen mode
--- origin without Vary
chrome      MISS image/avif  66799 bytes
firefox     HIT  image/avif  66799 bytes
safari      HIT  image/avif  66799 bytes
old client  HIT  image/avif  66799 bytes
cache entries: 1
--- origin with Vary: Accept
chrome      MISS image/avif  66799 bytes
firefox     MISS image/avif  66799 bytes
safari      MISS image/webp  106876 bytes
old client  MISS image/jpeg  153394 bytes
cache entries: 4
Enter fullscreen mode Exit fullscreen mode

Without Vary, Chrome warmed the cache with AVIF, and the */* client got AVIF too. In production that client is an old app, a crawler or an email proxy, and it shows a broken image.

Note: Vary: Accept fixes correctness and hurts your hit rate. Caches compare the raw header string, and every browser sends a different one. Chrome and Firefox both want AVIF, yet the cache stored it twice.

How do you keep the hit rate?

Normalise Accept before the cache looks it up, at the edge, to the one token your origin decides on:

const normalize = (a) => (/image\/avif/.test(a) ? 'image/avif' : /image\/webp/.test(a) ? 'image/webp' : 'image/jpeg');
Enter fullscreen mode Exit fullscreen mode
chrome      MISS image/avif  66799 bytes
firefox     HIT  image/avif  66799 bytes
safari      MISS image/webp  106876 bytes
old client  MISS image/jpeg  153394 bytes
cache entries: 3
Enter fullscreen mode Exit fullscreen mode

Three entries for three variants. Firefox now hits Chrome's AVIF. Where you put this depends on your CDN: a request rule, an edge function, or a custom cache key. This regex ignores q=0, so keep the origin's pick() as the source of truth.

Table of what each client got through the cache: without Vary all four got AVIF from one entry; with Vary: Accept each got its own format from four entries; normalised, three entries

Should you negotiate at all?

Negotiation keeps one URL, so it suits an image CDN or an API. If you control the HTML, <picture> with type="image/avif" lets Safari 16.4+ pick AVIF on its own and needs no Vary. For the byte gap between the three formats on more photos, see AVIF vs WebP vs JPEG.

How I measured

  • Machine: Apple M1 Pro, macOS, 2026-10-17.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, curl as shipped with macOS.
  • Source: image-compressor-sample-camera-photo.jpeg from the Blinkimg site (4964 × 2782), resized to 1920 px wide. AVIF quality 50, WebP quality 80, JPEG quality 80 with mozjpeg.
  • The cache is a short script, not a real CDN. It compares the exact header value, with no normalising of its own.
  • Accept values are copied from MDN's list on 2026-10-17, not captured from live browsers.

What I did not test

A real CDN's cache behaviour, Accept-CH or Client Hints, and JPEG XL. I did not capture Accept headers from live browsers.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (1)

Collapse
 
cdnca profile image
CDNCA • • Edited

The Vary: Accept trap is exactly where shared caches go wrong in production. Without Vary, the first client’s negotiated format (often AVIF) gets stored under a URL-only key and handed to wildcard-Accept clients as a broken image; with raw Vary: Accept, every browser’s Accept string becomes its own entry and hit rate collapses. Normalizing Accept at the edge to a small set of tokens (avif / webp / jpeg) before the cache lookup is the usual fix—same idea as normalizing device or compression dimensions so the key space stays finite.

I work on CDNCA, an open-source CDN control plane: CDN nodes and a plugin marketplace under one plane, with marketplace capability rolling out release by release. Content-negotiation correctness at the edge is one of those control-plane concerns that only gets louder as you add nodes.

When you put a CDN or reverse proxy in front of negotiated images, do you prefer Accept normalization in a cache key / request rule, or do you avoid negotiation entirely with distinct URLs / picture sources so Vary never enters the picture?