JPEG XL in the Accept header, and your CDN image cache
Chrome 155 shipped on 6 October with image/jxl at the front of the Accept header on every image request on desktop and Android, and Firefox 158 followed on 13 October. On iOS, Chrome and Firefox run on WebKit, not Blink or Gecko (Chromium, Firefox for iOS), so this post makes no claim about what they send. Before you weigh JPEG XL against AVIF, check what your CDN does with a request header whose bytes just changed.
A third image format is a cache-key problem before it is a codec problem. The codec question has good answers already: Jake Archibald’s JPEG XL vs AVIF comparison defaults to AVIF 4:4
for lossy web images and finds that JPEG XL “can make existing JPEGs 9–20% smaller, without further loss.” The negotiation rule itself is in AVIF vs WebP in 2026. mod_pagespeed, which I build, does not serve JPEG XL. A request that names onlyimage/jxl gets the original format (worker configuration docs).
What the browsers send now
The strings come from each browser’s source, plus a headless Chrome 155.0.8059.82 capture.
| Browser | Image Accept | Source |
|---|---|---|
| Chrome 154 | image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 | Chromium third_party/blink/common/loader/network_utils.cc |
| Chrome 155 | image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 | network_utils.cc at tag 155.0.8059.91; live capture |
| Firefox 158 | image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5 | Gecko nsHttpHandler.cpp at tag FIREFOX_158_0_RELEASE; image.jxl.enabled defaults to true from 158 |
Safari has decoded JPEG XL since Safari 17 (2023), and WebKit’s builder puts image/jxl after image/webp,image/avif where the platform has a decoder. So a raw-keyed cache already stores a Safari copy. In October, the Chrome and Firefox strings changed in place.
Neither Chrome nor Firefox puts a weight on image/jxl or image/avif, so both carry the default weight of 1. The header says “either is fine,” not “JPEG XL first.” The Chromium change that turned this on keeps the feature flag “as a kill switch,” so the string can also change back.
Three things a cache can do with Vary: Accept
A negotiating origin sends Vary: Accept. A cache in front has three options, and I ran all three today on stock nginx 1.30 with a test origin that returns AVIF, WebP or PNG. Each cache got four requests, twice: the old Chrome, Chrome 155 and Firefox 158 strings, and image/webp,*/* for a client without AVIF.
| Cache setup | Entries stored | First round | What the WebP-only client got |
|---|---|---|---|
Honours Vary on the raw header (nginx default) | 4 | 4 misses | WebP |
| Keys on the chosen format | 2 | 2 misses | WebP |
Ignores Accept | 1 | 1 miss | AVIF |
The raw-keyed cache stored the same AVIF three times (two Chrome strings, one Firefox), so it treats Chrome 155 as a new client. While the update rolls out “over the coming days/weeks” (Chrome Releases), each image is stored twice for Chrome, and its first Chrome 155 request at each cache node goes to the origin. It is a one-off refill, not a permanent doubling, and it recurs with every release that edits the string. The cache that ignores Accept stored one copy and gave AVIF to a client that never asked for it.
What four CDNs document
These are documented defaults, not bugs. Each option lands in a row above.
| CDN | Documented default | What the documented option does |
|---|---|---|
| Akamai | “Akamai edge servers don’t cache responses that include the Vary header, even if the content is cacheable by definition,” apart from Accept-Encoding (Remove Vary Header). On Adaptive Media Delivery, Download Delivery and Object Delivery, the behaviour that removes Vary is on by default. | Removing Vary makes the response cacheable, and the first response cached is what every client gets. |
| CloudFront | Accept: “CloudFront removes the header” before forwarding, and “caches only one version” unless the cache policy names the header (request headers, header caching). | Adding Accept to the cache policy keys on the value as sent, and letter case in the value counts. |
| Cloudflare | “If the vary object is omitted, this Cache Rules Vary setting is turned off” (Cache Rules settings); all plans since 2 July (changelog). passthrough keys on the raw value. For Accept, normalize lowercases the types, strips whitespace, sorts by quality value (alphabetically within a value), strips parameters, then drops the quality values, keeping q=0. | A media_types list removes every type not on it, so image/jxl drops out if you don’t list it. Vary for Images (Pro, Business and Enterprise, set through the API) parses Accept to pick a format, caches each variant separately, and needs the formats your origin serves listed per file extension (Vary for images). It is closest to the “keys on the chosen format” row. |
| Fastly | “Fastly does not normalize Accept by default,” and with Vary: Accept “cache performance may noticeably decline” (Accept). | accept.media_lookup in VCL picks one type from a list you supply. |
Four curl commands against your own CDN
Pick an image URL behind your CDN that nothing else requests, or add an unused query string if your CDN keys on query strings. Pin one edge so all eight requests reach the same cache, then run the block twice.
URL='https://cdn.example.com/img/hero.jpg?jxl-check=1'
PIN="cdn.example.com:443:$(dig +short cdn.example.com | tail -1)" # one edge IP
H='^(content-type|vary|age|x-cache|cf-cache-status|cache-status):'
c() { curl -s --resolve "$PIN" -o /dev/null -D - -H "Accept: $1" "$URL" | grep -iE "$H"; echo; }
c 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
c 'image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
c 'image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5'
c 'image/webp,*/*'
One IP can front several cache servers, so if misses don’t line up, read age too. How to read the second run:
- All four hit and all four have the same
content-type. The cache ignoresAccept, or strips it before the origin sees it (default CloudFront). - All four hit, the first three say
image/avifand the fourth saysimage/webp. The cache collapsesAcceptto a format. Check that the first run missed only twice. - The first run missed four times. The cache keys on raw bytes, and every new browser string costs a refill.
- Nothing ever hits. The CDN is not caching responses that vary.
Key the cache on the format decision
Decide the format once, then key on the decision, not the header. A setup that serves AVIF, WebP and the original has three slots per image URL, whatever the browsers send. JPEG XL would be a fourth slot, worth adding when you have a reason to serve it, and a browser that started asking is not one.
For an nginx cache in front of a negotiating origin, this is the tested config with one line changed:
map $http_accept $img_fmt {
~*image/avif avif;
~*image/webp webp;
default orig;
}
map $img_fmt $canon_accept {
avif "image/avif,image/webp,*/*";
webp "image/webp,*/*";
default "image/jpeg,image/png,image/gif"; # no wildcard
}
# in the image location:
proxy_set_header Accept $canon_accept; # the origin sees one Accept per slot
proxy_cache_key "$scheme$proxy_host$request_uri|$img_fmt";
proxy_ignore_headers Vary; # stop nginx keying on the raw header too
The proxy_set_header line matters most. An origin that sees the raw header can pick a format the cache key doesn’t know. That is also why the orig slot’s header has no wildcard: */* or image/* leaves the format to the origin, and whatever it picks lands in orig. List the original types the location serves, since RFC 9110 lets an origin answer 406 when nothing listed is acceptable.
The snippet doesn’t parse q-values, so image/avif;q=0 still counts as AVIF, and each slot’s header must be one your origin answers with that slot’s format. The rig ran with "*/*" as the default; its origin answers PNG to anything without image/avif or image/webp, so the table holds.
What mod_pagespeed does with these strings
mod_pagespeed reads Accept once per request and sets one image-format slot (AVIF, WebP or the original), preferring AVIF or WebP when the client advertises it (how classification works). Its CDN integration docs list the image Vary header as Accept, Save-Data, User-Agent, Sec-CH-DPR.
Measured today on modpagespeed.com with the four strings, the first round got the source PNG; the docs say a HIT “may contain original content if the worker hasn’t processed it yet” (response headers). From the second round on, the old Chrome, Chrome 155 and Firefox 158 strings all got the same 32,112-byte AVIF, and a request for image/jxl alone got a PNG.
There, the Chrome change added no variant in mod_pagespeed’s own cache. A CDN in front still sees the raw headers, so run the four commands there too.
Read next
-
Auto-vectorizing raster images to SVG: one variant for all
Raster to SVG auto-vectorization in mod_pagespeed: the optimizer worker traces logos and icons with VTracer, collapsing the variant matrix into one.
-
Is mod_pagespeed safe? How its rewrites are checked
Is mod_pagespeed safe? It rewrites your HTML, CSS, JS and images: how its rewrites are checked, and what happens when a check fails.
-
Stop cache fragmentation: strip tracking params from URLs
Strip tracking parameters to stop cache fragmentation: mod_pagespeed normalizes URLs before keying, drops UTM params, sorts the query, aliases hosts.