Skip to main content

On 6 October 2026 the Chrome team announced that Chrome 155 ships decoding support for JPEG XL. The post is unusually good: a Rust reimplementation of the decoder, real SIMD performance work, and a frank account of being argued into it by developer feedback. It closes by encouraging developers to “start using .jxl images and animations in your pipelines”.

So we did. We took a browser that now asks for JPEG XL by name and pointed it at the things that are supposed to answer: six image CDNs, the Node image library most JavaScript builds depend on, and the Next.js image optimiser.

One CDN out of six serves it. Three return HTTP 200 with a completely different format. The Node library writes a JPEG and names it .jxl without raising anything. Next.js detects the format and switches optimisation off. Not one of those layers produces an error, a warning, or a non-200 status. The pipeline does not reject JPEG XL; it absorbs it.

TL;DR

  • Chrome 155 reached stable on 6 October 2026 and now sends image/jxl first in its image Accept header, ahead of image/avif. The decoder is jxl-rs v0.7 in Rust, behind a build flag and a runtime feature that stays a remote kill switch.
  • One of six image CDNs picks JPEG XL under content negotiation; two more encode it only if asked explicitly. The other three return 200 with WebP or AVIF, so an explicit JXL request fails silently.
  • sharp(buf).toFile('out.jxl') does not throw. It writes a JPEG, reports format: "jpeg" and names the file .jxl. An invented extension behaves identically.
  • Next.js 16.4.0 puts image/jxl in BYPASS_TYPES alongside SVG and BMP, so next/image returns the upstream buffer untouched: the full-size original at every srcset width.
  • Across 16 photographs, JPEG XL beat AVIF on 11 and lost on 5, and the split was clean: AVIF won every image where its own output came under 53 KB, JXL won every one over 114 KB. There is no global pipeline switch here, only a per-image routing rule.

What we measured, and how

Everything below is first-hand. Chrome behaviour was read from the Chromium source at refs/heads/main, not from release notes. Each CDN got the exact image Accept header Chrome 155 sends, the same request without image/jxl, and an explicit format request, with the returned bytes sniffed rather than the Content-Type trusted.

Chrome’s half of the job is done, and it is flag-shaped

Chrome 155 hit early_stable on 23 September and stable_date on 6 October 2026. The image Accept header it sends is now:

image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8

JPEG XL is listed before AVIF, which matters: a CDN that walks the list in order and picks the first thing it can produce will now pick JXL.

One detail is worth knowing before you plan around it: support is not a static capability. In mime_util.cc, image/avif is an ordinary entry in a fixed set of supported image types; image/jxl is answered by a runtime call to base::FeatureList::IsEnabled(features::kJXLImageFormat). The feature ships FEATURE_ENABLED_BY_DEFAULT, and the comment above its declaration is explicit: “If disabled, JXL images will not be decoded even if the decoder is built.” That is a remotely operable kill switch sitting under your <picture> element. Mobile is in scope too: the Android manifest template registers image/jxl wherever the decoder is compiled in.

Six CDNs, one answer

We asked each CDN for the same image three ways: its default delivery URL with image/jxl in Accept, the same URL without it, and an explicit JXL request in the platform’s own syntax.

Platform Default URL, JXL advertised Explicit JXL request Vary
Cloudinary image/jxl image/jxl Save-Data
imgix image/avif image/jxl Accept, User-Agent
images.weserv.nl image/jpeg image/jxl none
ImageKit image/avif image/avif Accept,Save-Data
Shopify CDN image/webp image/webp Accept
Storyblok image/webp image/webp Accept

Cloudinary is the only one that reads the header and acts on it. One f_auto,q_auto URL returned four bodies with four ETags across four Accept headers: JXL at 40,594 bytes, AVIF at 41,448, WebP at 50,482 and JPEG at 101,100, from a 120,253 byte original.

Now look at the right-hand column. Cloudinary negotiates on Accept and declares Vary: Save-Data, with no Accept in it. What protects you is Cache-Control: private, which Cloudinary does send, and which means these four variants cannot be stored by any shared cache. Put Cloudflare or Fastly in front of Cloudinary and nothing caches; override that with a cache-everything rule, which is a very common thing to do, and you will start handing JXL bytes to browsers that cannot read them.

The three platforms at the bottom of the table are the more dangerous case. ImageKit’s f-jxl returned AVIF, byte-identical to its f-auto response. Shopify’s format=jxl returned WebP. Storyblok’s filters:format(jxl) returned WebP byte-identical to the unfiltered URL, and a format name we invented returned exactly the same thing. All three answered 200 with a correct Content-Type for what they actually sent. Point a <source type="image/jxl"> at one of those URLs and Chrome will select it, fetch it, receive WebP, render it perfectly and report nothing. You will have shipped a JPEG XL migration that transfers not one JPEG XL byte.

Cloudflare has not heard of it

Cloudflare publishes its entire documentation set as a single file. We pulled it: 10,025,902 bytes, containing 69 occurrences of “avif” and zero word-boundary occurrences of “jxl”, “JXL” or “JPEG XL”.

The sharper problem is Vary for Images, the feature that lets your origin negotiate formats behind Cloudflare at all. It is Pro and above, and it operates on an enumerated extension allowlist: .avif, .bmp, .gif, .jpg, .jpeg, .jp2, .png, .tif, .tiff, .webp. No .jxl. Cloudflare’s own documentation states the failure mode: if the origin sends Vary: Accept but does not serve a configured variant, “the response is not cached and displays BYPASS in the cache status”. Negotiate JPEG XL at your origin behind Cloudflare and your images stop being cached.

Your build cannot make the file

sharp sits underneath most Node build pipelines. The current release, 0.35.5, bundles libvips 8.18.7 and names 27 libraries beside it, including aom for AV1, heif, webp and mozjpeg. There is no libjxl.

sharp reports this honestly if you ask: sharp.format.jxl returns input and output false for file, buffer and stream alike. Calling the method confirms it:

sharp(buf).jxl() → VipsOperation: class "jxlsave_buffer" not found

That is the loud failure, and it is the only one. Here is the quiet one:

await sharp(jpegBuffer).toFile('out.jxl')

No exception. The returned metadata says format: 'jpeg', 3,118 bytes. The file on disk begins ff d8: a JPEG, named .jxl. Hand it a PNG source and out.jxl is a PNG. The same call with the extension .bogusext gave a byte-identical result, which is the explanation: libvips resolves the writer from the suffix, does not recognise .jxl, and falls back to the input format. .avif and .webp work correctly, so the asymmetry is invisible in a build script that handles three formats and assumes the fourth behaves like the others.

Decoding fails too: feeding sharp a real JPEG XL file, fetched from imgix, returns Input file contains unsupported image format.

Next.js switches optimisation off

In Next.js 16.4.0 the configurable output type is type ImageFormat = 'image/avif' | 'image/webp', and the config schema enforces it. You cannot ask next/image to emit JPEG XL.

Input is more interesting. Next.js does detect JPEG XL, both the raw codestream signature and the container form, then places image/jxl in BYPASS_TYPES next to SVG, ICO, BMP and HEIC. That handler returns the upstream buffer unchanged. No resize, no re-encode, no quality pass: every device requesting every srcset width receives the full-resolution original. It is the one outcome worse than doing nothing, and it is silent. We have written before about how much of this subsystem is invisible from your package.json.

WordPress is not there at all. The current wp_get_mime_types() in trunk has no jxl entry, so the media library rejects the upload until somebody adds a filter.

Is it worth the trouble?

We pushed 16 random landscape photographs through one encoder at a fixed 1600px width and quality parameter 75, in all four formats.

Comparison Median saving Smaller in
JXL vs JPEG 63.5% 16 of 16
JXL vs WebP 63.2% 16 of 16
AVIF vs JPEG 38.4% 16 of 16
JXL vs AVIF 33.8% 11 of 16

That last row is the one to read twice. JPEG XL lost to AVIF on five of sixteen images, by as much as 33.6%, and the split is not noisy. AVIF won every image where its own output came in at 52,560 bytes or less. JPEG XL won every image where AVIF’s output exceeded 114,164 bytes. Nothing landed in between.

So the useful rule is not “switch to JPEG XL”. It is: keep AVIF for small, flat, low-detail images, and route heavy photographic images to JPEG XL. That is a per-asset decision, which means a real <picture> element with measured sources, not a one-line configuration change.

One caveat: quality 75 is not perceptually equivalent across four codecs, so this is a comparison at a matched quality parameter, not matched perceived quality. Where an encoder targets perceptual quality per format instead, the gap narrows sharply. Cloudinary’s q_auto produced 40,594 bytes of JPEG XL against 41,448 of AVIF: a 2% improvement, not a 34% one.

The other browsers

Firefox marks 158 as full support, but the current release is 157.0.1, where JPEG XL is still an about:config flag. Edge 154 and Samsung Internet 30 have nothing.

Safari and iOS Safari, 26.x through 27.2, are marked partial, and the two limitations are pointed: animated image sequences are not supported, and progressive decoding is not supported. Those are precisely the two capabilities Chrome’s announcement names as JPEG XL’s advantages, and precisely what it invites you to start shipping.

What to do this week

  1. Test your CDN before you plan around it. One request with Accept: image/jxl,..., one without, and sniff the first four bytes. Never trust a 200.
  2. Assert on format in CI, not on exit codes. If a build step writes .jxl, read the magic bytes back. sharp.format.jxl.output.file is a one-line precondition.
  3. Check what your edge does with Vary. If you negotiate at origin, confirm your CDN honours it for that extension, and that the fallback is a cache miss you can afford.
  4. Do not put .jxl through next/image. Until the bypass list changes, those files leave your server at full resolution.
  5. Decide per image, not per pipeline. If AVIF already gets your heroes under 50 KB, JPEG XL will probably make them worse.

Chrome did the hard part properly. The failure is downstream, and it is a failure of signalling rather than capability: every layer that cannot do this yet says 200, or writes a file, or returns a buffer. A migration where nothing fails is one you cannot verify, and the teams that get burned will be the ones who shipped a <picture> element, saw it render, and assumed.

At REPTILEHAUS we build and operate this layer: image pipelines that assert on output format rather than exit status, CDN configurations understood before they are relied on, and front-end delivery measured against real Core Web Vitals rather than a vendor claim. If you have an f_auto or auto=format somewhere in your stack and nobody can say what it returns to Chrome today, that is worth half a day of somebody’s time. Get in touch and we will measure it with you.

📷 Photo by Evgeniy Bezkorovayniy on Unsplash