Serve AVIF or WebP automatically from Node.js based on the Accept header (and why you need Vary: Accept)
You have a folder of JPEGs and PNGs, and you've generated AVIF and WebP versions next to them (for example with the script from Batch convert a folder of images to WebP and AVIF). Now you want browsers that support the smaller formats to get them, without changing every <img src="photo.jpg"> in your HTML.
Browsers say what they can display in the Accept header of image requests, so the server can pick. This post builds that as an Express middleware, and shows why the version this post originally published was subtly broken. Everything ran on node:24-slim with [email protected]. The browser checks used headless Chromium 153, Firefox 155 and WebKit 26.6 from the official Playwright Docker image.
What browsers send
The Accept header each engine sent for a plain <img src="/photo.jpg">:
chromium 153 image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
firefox 155 image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5
webkit 26.6 image/webp,image/avif,image/jxl,video/*;q=0.8,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5All three advertise AVIF and WebP. (Playwright's WebKit build on Linux is close to Safari, but not identical.) The string is different in every browser, which matters later for caching.
The middleware
modern-images.mjs:
import { stat } from 'node:fs/promises';
import path from 'node:path';
const SOURCE = /\.(jpe?g|png)$/i;
const FORMATS = ['avif', 'webp']; // best first
/**
* Serve photo.avif or photo.webp instead of photo.jpg when the browser accepts
* it and the file exists. Mount it before express.static().
*/
export function modernImages(root) {
root = path.resolve(root);
return async (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
// req.path has no query string, so /photo.jpg?v=2 still matches.
if (!SOURCE.test(req.path)) return next();
// From here on the response depends on the Accept header, even when we
// fall back to the original, so caches must keep one copy per Accept value.
res.vary('Accept');
const accept = req.get('accept') ?? '';
for (const format of FORMATS) {
if (!accept.includes(`image/${format}`)) continue;
const candidate = req.path.replace(SOURCE, `.${format}`);
let file;
try {
file = path.join(root, decodeURIComponent(candidate));
} catch {
return next(); // malformed %-encoding: let express.static answer
}
if (!file.startsWith(root + path.sep)) return next(); // no ../ tricks
try {
if ((await stat(file)).isFile()) {
req.url = candidate + req.url.slice(req.path.length); // keep the query string
return next();
}
} catch {
// no such variant, try the next format
}
}
next();
};
}Mount it in front of express.static. It only rewrites req.url, and the static middleware does the actual serving, including Content-Type, ETag and range requests:
import path from 'node:path';
import express from 'express';
import { modernImages } from './modern-images.mjs';
const app = express();
const root = path.join(import.meta.dirname, 'public');
app.use(modernImages(root));
app.use(express.static(root));
app.listen(3000, () => console.log('serving on http://localhost:3000'));It looks for photo.avif and photo.webp with the same base name as photo.jpg, and prefers AVIF because it's usually the smaller file. That holds even in WebKit, which lists WebP first.
Testing it
/photo.jpg Chromium's Accept → 200 image/avif vary: Accept
/photo.jpg image/webp,*/* → 200 image/webp vary: Accept
/photo.jpg (no Accept) → 200 image/jpeg vary: Accept
/photo.jpg?v=2 Chromium's Accept → 200 image/avif vary: Accept
/LOGO.PNG Chromium's Accept → 200 image/avif vary: Accept
/%2e%2e/package.json Chromium's Accept → 404 text/html vary: none
/missing.jpg Chromium's Accept → 404 text/html vary: AcceptThe 5.3 MB test photo went out as a 222 kB AVIF to all three browsers, and they decoded it at its full 1600 px width.
Four bugs in the original version
The first version of this post published a callback-based middleware that looked for photo.jpg.avif-style files. Running it unchanged on Express 5:
/photo.jpg Chromium's Accept → 200 image/avif vary: none
/photo.jpg image/webp,*/* → 200 image/webp vary: none
/photo.jpg (no Accept) → 200 image/jpeg vary: none
/photo.jpg?v=2 Chromium's Accept → 200 image/jpeg vary: none
/LOGO.PNG Chromium's Accept → 200 image/png vary: none
(node:3012) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.- No
Vary: Accept, explained in the next section. This is the serious one. - Query strings broke it.
path.extname(req.url)on/photo.jpg?v=2returns.jpg?v=2, so cache-busted URLs (which most build tools produce) never got AVIF.req.pathexcludes the query string. - Extensions were case-sensitive, so
LOGO.PNGwas skipped. The regular expression above uses/i. url.parse()is deprecated, and recent Node versions print a runtime warning for it.
Why Vary: Accept is required
Once the same URL can return different bytes depending on a request header, every cache between you and the browser has to know that. CDNs, reverse proxies and the browser's own cache are all caches. Vary: Accept tells them to store one copy per Accept value. Without it, a cache stores whatever the first visitor received and gives it to everyone.
To show it, I put an nginx proxy_cache in front of each version (configured to cache images, as a CDN rule would). A browser that supports AVIF requests the photo first, then a client that only takes PNG/JPEG requests the same URL:
== original middleware behind a cache
Accept: image/avif,image/webp,*/* → image/avif X-Cache: MISS
Accept: image/png,image/*;q=0.8 → image/avif X-Cache: HIT ← wrong format, from cache
== new middleware behind a cache
Accept: image/avif,image/webp,*/* → image/avif X-Cache: MISS
Accept: image/png,image/*;q=0.8 → image/jpeg X-Cache: MISS ← correctNote that the middleware sets Vary on the JPEG fallback too, not only when it swaps the file. The fallback is also a choice made from Accept.
The catch: as the first section showed, each browser sends a different Accept string, and versions change it. So Vary: Accept can split one image into many cache entries. That's correct but less efficient. Many CDNs can normalise Accept to a few buckets, or have their own image-format negotiation, so check your CDN's docs if you serve a lot of traffic this way.
Or skip the server logic: <picture>
If you control the HTML, the browser can choose for itself:
<picture>
<source srcset="/photo.avif" type="image/avif">
<source srcset="/photo.webp" type="image/webp">
<img src="/photo.jpg" alt="…" width="1600" height="2961">
</picture>All three engines picked /photo.avif. Each format has its own URL, so caches need no Vary at all, and it works on any static host. Use <picture> when you can edit the markup. The middleware is for when you can't: HTML from a CMS, user content, old templates, or CSS background-image URLs.