Handling Image Format Negotiation in Headless Pipelines

Image format negotiation breaks in headless pipelines because most content APIs are stateless data endpoints that never inspect the Accept header. Without explicit negotiation logic, the CDN caches one default format and serves it to everyone, inflating payloads and fragmenting the edge cache. Getting it right means coordinating header handling across the content layer, edge compute, and frontend markup. This guide is part of Image Optimization Pipelines for CMS Assets.

Negotiation strategies comparedThree ways to deliver modern formats: server-side negotiation on the Accept header, explicit format URLs chosen by picture elements, and a CMS or CDN image API with automatic format; compared on cache keys, static build support and HTML size.StrategyCache keyStatic buildsMarkupAccept negotiation at edgeURL + normalized formatyessingle imgpicture with typed sourcesURL per formatyeslarger HTMLImage API auto formathandled by provideryessingle img
Picture elements work everywhere; Accept negotiation keeps HTML simpler but needs careful caching.

The Content Negotiation Handshake

Browsers advertise codec support through the Accept request header. A current Chromium client sends image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8; older agents fall back to image/jpeg or image/png. Headless content APIs typically ignore this header and rely on explicit URL parameters or a downstream edge worker to pick the variant.

The common production failure is header stripping: reverse proxies and API gateways drop or normalize Accept before it reaches the origin. The origin then caches a single default format and serves it indiscriminately — silent format regression across every device segment, with the bandwidth bill to match.

Cache Keys and the Vary Header

Edge caches key on the request URI. Without Vary: Accept on origin responses, the first cached variant becomes the canonical response for all clients, which breaks HTTP content negotiation outright.

The failure looks like this: a legacy crawler requests the JPEG variant first, the edge caches it, and modern browsers asking for AVIF get the stale JPEG — negating every byte you saved. Fix it on two sides: append a format suffix to the CDN cache key, and set Vary: Accept at the origin. The relevant directives are in the HTTP Caching specification.

Edge Worker and Fallback Chain

Enforce a deterministic resolution chain at the edge: AVIF, then WebP, then JPEG/PNG. The worker parses Accept, rewrites the asset URL, and normalizes the cache key before the request hits the origin or transformation service.

The worker’s resolution-and-cache path looks like this:

The edge worker's resolution pathThe worker reads the Accept header, chooses AVIF, then WebP, then JPEG, normalizes the cache key with the chosen format, and serves a cached variant on a hit or fetches the origin with the format parameter on a miss, storing the result with immutable caching.RequestAccept headerChoose formatavif > webp > jpegCache key+ formatCachehit?Serve cachedvariantFetch originfm=formatyesnostore
The chosen format is part of the cache key, so variants can never leak between clients.
TypeScript
/**
 * Edge worker for deterministic image format negotiation.
 * Compatible with Cloudflare Workers, Vercel Edge, and Deno Deploy.
 */
export interface Env {
  IMAGE_ORIGIN: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const acceptHeader = request.headers.get('accept') || '';
    
    // Deterministic format resolution chain
    const format = acceptHeader.includes('image/avif') ? 'avif'
      : acceptHeader.includes('image/webp') ? 'webp'
      : 'jpeg';

    // Rewrite URL with explicit format parameter for origin processing
    url.searchParams.set('fm', format);

    // Normalize cache key to prevent cross-format leakage
    // Use a dedicated key parameter; never reuse names like `v` that version tokens already use.
    const cacheKey = new URL(url.toString());
    cacheKey.searchParams.set('__fmt', format);

    const cache = caches.default;
    let response = await cache.match(cacheKey);

    if (!response) {
      response = await fetch(url.toString(), { headers: request.headers });
      
      // Enforce Vary header and cache control
      const headers = new Headers(response.headers);
      headers.set('Vary', 'Accept');
      headers.set('Cache-Control', 'public, max-age=31536000, immutable');
      headers.set('X-Format-Negotiated', format);

      response = new Response(response.body, {
        status: response.status,
        statusText: response.statusText,
        headers
      });

      await cache.put(cacheKey, response.clone());
    }

    return response;
  }
};

The format-suffixed cache key keeps variants from leaking across clients, and Vary: Accept documents the negotiation in the response.

SSG/SSR: Defer the Final Choice to the Browser

Static generation pre-renders markup at build time, where there is no browser context and no Accept header to read. Hardcoding one format at build forces legacy devices to decode codecs they don’t support.

Push the final choice back to the browser with a <picture> element. The browser walks the <source> list in order and takes the first supported type, so negotiation happens client-side with no build-time assumptions.

HTML
<picture>
  <source srcset="/assets/hero.avif" type="image/avif">
  <source srcset="/assets/hero.webp" type="image/webp">
  <img src="/assets/hero.jpg" alt="Hero banner" width="1200" height="630" loading="eager">
</picture>

The <picture> element and its decoding rules are specified in the WHATWG HTML Living Standard.

Localization and Asset Routing

Locale prefixes, regional CDN nodes, and localized metadata can all disrupt delivery if negotiation isn’t applied uniformly. Edge workers must respect locale prefixes while keeping format resolution identical across regions. Format choice should depend only on what the browser supports, never on the region: a bandwidth-constrained market benefits most from the smallest format the device can decode. Fold these rules into your broader Image Optimization Pipelines for CMS Assets.

Define fallback behavior explicitly for missing localized variants: resolve to the default locale’s optimized binary rather than 404ing or regressing the format. Deterministic edge routing keeps content fallback and asset delivery in step.

Production Checklist

  • Set Vary: Accept on all origin image responses to prevent cache poisoning.
  • Normalize CDN cache keys with explicit format suffixes or query parameters.
  • Deploy edge workers that parse Accept and rewrite URLs deterministically.
  • Use <picture> in SSG/SSR templates to delegate codec selection to the browser.
  • Sync locale-specific routing with regional CDN fallback chains.
  • Monitor LCP, cache hit ratio, and format distribution to catch silent regression.

Testing Negotiation

Negotiation bugs are invisible in a modern browser, which always receives the best format, so test with explicit headers. A small script requests each sample image three times with different Accept headers, one advertising AVIF, one WebP only and one */*, and asserts the Content-Type of each response. Run it against the CDN, not just the origin, and run it twice in a row: the second pass catches cache poisoning, where the first response for one header is served to the others. Add the same check to the post-deploy audit, and chart the distribution of image formats served from CDN logs, which reveals regressions such as a configuration change that silently disabled AVIF.

Bash
for accept in "image/avif,image/webp,*/*" "image/webp,*/*" "*/*"; do
  curl -s -o /dev/null -w "%{content_type}  $accept\n" -H "Accept: $accept" https://images.example.com/hero-42.jpg
done

Where Negotiation Should Live

There are three reasonable places to negotiate formats, and a pipeline should pick one. The image provider: many CMS image APIs and CDN image services accept an automatic format option, read the Accept header themselves and handle caching correctly; if yours does, use it and skip the worker. An edge worker: needed when the provider requires an explicit format parameter, as in the example above, or when you want control over the choice. The markup: picture elements with typed sources, where the browser chooses; best for static builds and when you want no header logic at all. Doing it in two places at once, for example a worker rewriting URLs that already request automatic format, leads to confusing results and doubled cache entries.

Gotchas & Edge Cases

  • Accept headers that lie. Some clients advertise formats inconsistently, and crawlers often send */*. Treat anything without explicit AVIF or WebP support as JPEG-only.
  • Vary: Accept fragmentation. Varying on the raw Accept header creates a cache entry per distinct header string. Normalize to the chosen format in the cache key, as the worker does, rather than relying on Vary alone at the CDN.
  • Downloads and social previews. Social crawlers may not support AVIF. Point og:image at a JPEG or PNG explicitly.
  • Transparency. JPEG has no alpha channel; the fallback for transparent images must be PNG, not JPEG.

Worked Example

A news site’s CDN cached the first format it saw for each image. Because a monitoring bot without AVIF support refreshed popular images every few minutes, most visitors received JPEGs. Adding the edge worker with a normalized format in the cache key raised the share of AVIF responses to match browser support, about 90 percent of requests, and reduced image bandwidth by roughly a third without any change to page markup.

Share of image responses by formatImage responses by format before and after cache-key normalization, showing JPEG dominance caused by cache poisoning and the shift to AVIF afterwards.JPEG before71 % of responsesAVIF before18 % of responsesJPEG after6 % of responsesAVIF after89 % of responses
The cache, not the browsers, had been choosing the format.

Frequently Asked Questions

Is picture or Accept negotiation better?

Both work. picture is explicit and cache-friendly by URL; Accept negotiation keeps markup small. Many CMS image APIs offer an automatic format option that does the negotiation for you.

Do we still need JPEG fallbacks?

For a small share of browsers and many bots, yes. Keep them, generated on demand so they cost nothing until requested.

Should format be part of the URL?

With picture, yes. With Accept negotiation, the public URL stays the same, and the format goes into the internal cache key.

What about JPEG XL?

Browser support is limited. Treat it like any other optional format: offer it only to clients that advertise support, after AVIF or WebP.

How much does AVIF save compared with WebP?

Typically 20 to 30 percent at similar visual quality for photographs, less for graphics. Measure on your own images before tuning quality settings.

Does negotiation affect LCP?

Yes, positively: smaller files for the LCP image download faster. Make sure the LCP image’s format negotiation does not add a redirect or an extra request, since that delay would outweigh the saved bytes.

Can the CMS preview show modern formats?

Yes, if preview images go through the same pipeline. It is worth doing, so editors see the same compression artefacts, if any, that readers will.