Home → Serve AVIF or WebP automatically from Node.js based on the Accept header (and why you need Vary: Accept)

Serve AVIF or WebP automatically from Node.js based on the Accept header (and why you need Vary: Accept)

By · Node.js & JavaScript developer
Published November 8, 2022 · Updated October 1, 2026

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.5

All 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: Accept

The 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.
  1. No Vary: Accept, explained in the next section. This is the serious one.
  2. Query strings broke it. path.extname(req.url) on /photo.jpg?v=2 returns .jpg?v=2, so cache-busted URLs (which most build tools produce) never got AVIF. req.path excludes the query string.
  3. Extensions were case-sensitive, so LOGO.PNG was skipped. The regular expression above uses /i.
  4. 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   ← correct

Note 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.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.