4007 stories
·
4 followers

TanStack Start 1.0 is Stable

1 Share
TanStack Start 1.0 is stable and ready for production.

Read the whole story
emrox
3 hours ago
reply
Hamburg, Germany
Share this story
Delete

On Short Notice

1 Share

On Short Notice

And more meetings.

Read the whole story
emrox
6 hours ago
reply
Hamburg, Germany
Share this story
Delete

JPEG XL vs AVIF for web use-cases

1 Share

JPEG XL has now landed in all major browsers, so I figured I'd take a closer look at what it's good/bad at compared to other formats.

Conclusions

I'll go into full detail, with examples and benchmarks, but for those of you who don't have the time, here are the headlines:

For lossy photos on the web, AVIF almost always produces smaller files than JPEG XL. At default effort settings, the best JPEG XL version is typically 15–25% larger than the equivalent AVIF. At maximum effort settings, the gap narrows a little, to typically 6–20%. There are outliers, where JPEG XL is close to, or even beats AVIF, so if you want the absolute best result for a particular image, it's worth trying both. See the F1 image for a typical example, and the weasel for an outlier.

The JPEG XL encoder matters a lot. jixel, a newer JPEG XL encoder, usually produces smaller files than libjxl, except when the image has detailed transparency. The people image is one example.

For screenshots and illustrations, AVIF is much better than JPEG XL. The equivalent JPEG XL is 30–90% larger at default effort settings, and 20–70% larger at maximum effort settings. Lossy AVIF handles these well - you don't need to use lossless formats unless you really need bit-exact results. See the screenshot and illustration examples.

For images with transparency, AVIF is much better than JPEG XL. The libjxl version is 31–44% larger at default effort settings, and 61–70% larger at maximum effort settings. jixel always encodes transparency losslessly, which makes its files 2–3x the size of AVIF's. See the data.

AVIF is generally faster to encode, and uses less memory, when you factor in file size. jixel is faster than AVIF and uses less memory, but its files are typically significantly larger, so AVIF is better on balance. See the F1 image for one example.

AVIF is faster to decode. JPEG XL typically takes 2–3x as long to decode at medium quality, but the gap narrows at very high qualities, where Chrome can sometimes decode JPEG XL faster. The people image is one example.

Higher effort settings are worth it for AVIF and jixel, but not libjxl. libjxl's higher efforts are much slower, use a lot more memory, and barely improve the result. Sometimes they produce larger files than the default effort. The screenshot is an example of this.

AVIF's 4:2:0 mode (half colour resolution) shouldn't be your default. It rarely reduces file size, and it makes higher qualities harder to reach. Although, it is quicker to encode and decode. Stick with 4:4:4 if you don't have time to check both. The F1 image is one example, and the screenshot is an exception.

Quality settings are reliable for all the encoders. With a single quality setting, the resulting quality varies a bit between images, but all the encoders vary in the same direction, and in ways that seem reasonable. See the data.

Progressive JPEG XL is basically free at high effort. jixel recently added support for early progressive previews, and at its slowest setting they cost little or nothing, but at its default setting the files are 12% bigger. Firefox and Chrome support progressive JPEG XL. Safari still renders the final image, but doesn't show progressive previews. However, is this type of progressive rendering a good user experience? See the examples and data.

AVIF supports progressive rendering! It's Chrome-only for now, but it's in progress for Firefox. It's more complex than progressive JPEG XL, but it gives you more control over what the preview looks like, the files are usually smaller, and they're much faster to decode. See for yourself.

For HDR, JPEG XL lacks gain maps, which is a significant limitation compared to AVIF. The JPEG XL format supports gain maps, but they aren't supported by browser decoders, and the encoding story is messy/experimental. When comparing non-gain mapped HDR content, the two quality metrics I used disagree a lot, but they agree that JPEG XL produces smaller files compared to AVIF. See the examples.

JPEG XL can make existing JPEGs 9–20% smaller, without further loss. It's also as fast or faster to decode, except for small images. See the data.

For lossless, JPEG XL is the clear winner. Lossless WebP is 9–17% larger for photos, and lossless AVIF is 30–120% larger. But lossless images are almost never what you want on the web. If you do use them on the web (like I do on this page), watch out for jixel's higher efforts - it often produces files that are too slow to decode. See the data.

Animated image formats continue to be a dumb idea. Please don't use them. Learn more, if you must.

For web use-cases, I'd currently default to AVIF 4:4:4, with the highest effort level I can be bothered with. If I felt AVIF was producing an unusually large file, I'd take a look at JPEG XL via jixel, and see if it did better.

Both AVIF and JPEG XL (via jixel) have seen massive improvements recently, so it's possible that this recommendation could change in future. There's been a lot of misinformation around this stuff, and your AI will happily deliver that misinformation to you. Instead, test things with your own images.

AVIF folks suggest that JPEG XL is technically limited compared to AVIF. That might be true, but in this article I'm focusing on results with today's implementations.

Methodology

I 'created' a benchmarking script, that encodes images using various codecs at various settings, and records various details.

I'm using the latest released versions of the image codecs, which at time of writing is:

  • avifenc 1.4.2
  • libjxl 0.12.0 (using its cjxl encoder)
  • jixel 0.4.2 (git f487299), a second JPEG XL encoder
  • cwebp 1.6.0 (lossless WebP is used later in the article)

I chose a selection of images, and encoded most of them to a width of 1598 pixels - which is 2x the maximum width images appear on this blog. This means they look sharp on high density displays, which most users have. As a result, if you're viewing this on a small or low density screen, you're not getting the full experience.

I ran these benchmarks on my 2024 MacBook Pro (M4 Pro).

Lossy - F1 image

When I first wrote about AVIF, I used an F1 car as the first image, so I figured I'd do that again!

I encoded the image at various codec qualities, and chose versions that were closest to particular SSIMULACRA2 scores - a perceptual image quality metric by the folks behind JPEG XL. 50 is considered medium quality, 70 high, 80 very high, and 90 visually lossless. Although, I'm not sure if these ratings take high density displays into account.

Throughout the article, the visual comparisons, like above, use each encoder's maximum effort, except in cases where maximum effort results in a larger file (yes, that happens).

For archival, I'd tend towards near-lossless qualities, but if I'm just displaying an illustrative image on my blog, performance is more important to me. I'm personally happy with the 70 result above. If you zoom in, you can see the quality loss, but if I was really concerned about the zoom-in case, I'd use something like the VisualViewport API to load in a higher resolution image when that happens, rather than make the download bigger for everyone.

At lower qualities you can see the difference in strategy between the codecs. The detail loss is around the same, but AVIF tends to smooth things out compared to the original, whereas JPEG XL seems to introduce rougher patterns.

Also, I feel that, at medium quality (50), the AVIF and libjxl look better than the jixel version. The jixel version has a lot of visible noise around the BWT logo, and the Microsoft text on the front wing is way less legible. It makes me wonder about the metric a bit.

Anyway, here's my interpretation of the results:

  • File size: AVIF wins. At default efforts, the best JPEG XL image is 14–17% larger than the AVIF image for the same quality. jixel is the better JPEG XL encoder at medium/high quality, and libjxl is slightly better at very high quality. At maximum efforts, the best JPEG XL image (jixel 'extraslow') is 6–15% larger than AVIF (s0).
  • jixel at its slowest setting slightly beats AVIF's default effort, with files 1–10% smaller. The biggest gain is at very high quality.
  • Encode time: AVIF wins. At default efforts, jixel is 12–36% faster than AVIF, but its files are 14–18% larger. To match the file size of AVIF's default, jixel needs its 'extraslow' setting, which takes 18–27x as long as AVIF. libjxl's default takes 1.5–2x as long as AVIF's default, and still produces bigger files.
  • Encode memory: AVIF wins. At default efforts, jixel uses around half the memory of AVIF, but again, its files are 14–18% larger. To match the file size of AVIF's default, jixel 'extraslow' uses ~10x the memory. libjxl's default uses 2–3x the memory of AVIF, and its higher efforts use ~7x.
  • Decode time: AVIF wins. JPEG XL takes 2–3x as long to decode at medium quality, narrowing to ~1.5–1.8x at very high quality. Safari's JPEG XL decoder is the slowest.
  • Higher effort is worth it for AVIF. AVIF's s5 gets you ~10% smaller files for ~3x the encode time.
  • Higher effort is worth it for jixel too, up to 'slow'. 'slow' gets you 9–13% smaller files than its default 'fast', for ~3.7x the encode time. 'extraslow' saves another 3–8%, but takes ~8x longer than 'slow' and uses ~9x the memory. Even so, AVIF still wins when given the same encode time: AVIF s5 takes about as long as jixel 'slow', and its files are ~13% smaller.
  • Higher effort isn't worth it for libjxl. libjxl's maximum effort is barely any better than its default, but takes ~18x longer.
  • AVIF's 4:2:0 mode doesn't help much. It's slightly faster to encode/decode, and uses less memory, but the files aren't meaningfully smaller, and it struggles to reach higher qualities.

Here's the full data, so you can make up your own mind:

Lossy - people

I've heard people say that AVIF's tendency to smooth out details makes it bad for human faces, so I picked this image from Unsplash.

The results of the benchmark for this image:

  • File size: AVIF wins. At default efforts, the best JPEG XL image is 17–24% larger than the AVIF image for the same quality. jixel is the better JPEG XL encoder across the whole quality range this time. libjxl is 30–50% larger than AVIF, which is a much bigger gap than the F1 image. At maximum efforts, the best JPEG XL image (jixel 'extraslow') is 12–17% larger than AVIF (s0).
  • jixel at its slowest setting doesn't quite match AVIF's default effort this time. It's a few percent larger at medium/high quality, and only edges ahead at very high quality.
  • Encode time: AVIF wins. At default efforts, jixel is 18–33% faster than AVIF, but its files are 17–24% larger. Even jixel's 'extraslow' setting, which takes 25–39x as long as AVIF, only matches AVIF's default file size at very high quality. libjxl's default takes 1.5–2.2x as long as AVIF's default, and still produces bigger files.
  • Encode memory: AVIF wins. At default efforts, jixel uses around half the memory of AVIF, but again, its files are 17–24% larger. jixel 'extraslow' gets closest to AVIF's default file size, but uses ~10x the memory. libjxl's default uses 2–3.3x the memory of AVIF, and its higher efforts use ~7x.
  • Decode time: AVIF wins. JPEG XL takes 2–2.9x as long to decode at medium quality, narrowing to ~1.3–1.7x at very high quality. At the very top of the quality range, libjxl images decode as fast as AVIF in Chrome, but not in the other browsers.
  • Higher effort is worth it for AVIF. AVIF's s5 gets you 5–8% smaller files for ~3x the encode time. That's a smaller gain than the F1 image.
  • Higher effort is worth it for jixel too, up to 'slow'. 'slow' gets you 7–14% smaller files than its default 'fast', for ~3.9x the encode time. 'extraslow' saves another 4–6%, but takes ~10x longer than 'slow' and uses ~9x the memory. AVIF still wins when given the same encode time: AVIF s5 takes about as long as jixel 'slow', and its files are 8–17% smaller.
  • Higher effort is somewhat worth it for libjxl on this image. Unlike the F1 image, libjxl's maximum effort gets you 4–11% smaller files than its default. But it takes ~10x longer, and it still produces much bigger files than AVIF at its default effort.
  • AVIF's 4:2:0 mode doesn't help much. Same as the F1 image. ColorVideoVDP gives it a small (~4%) advantage, but SSIMULACRA2 doesn't.

At lower qualities, you can see the smoothing on the face, but it's the same for both AVIF and JPEG XL.

Lossy - red panda

I threw this one in because it's the default image in Squoosh, but I guess the sharp lines of the hairs make it different to the previous images. In fact, even at medium quality, the difference from the original is much less perceptible to my eyes compared to the other images.

The results are much closer than the previous two:

  • File size: AVIF wins, narrowly. At default efforts, the best JPEG XL image (jixel) is only 2–6% larger than the AVIF image for the same quality. libjxl does much worse, at 10–18% larger. At maximum efforts, jixel 'extraslow' and AVIF s0 are roughly tied, with jixel up to 5% smaller at very high quality. However, ColorVideoVDP disagrees, and puts AVIF 5–12% ahead at maximum efforts.
  • Encode time: JPEG XL (jixel) wins, according to SSIMULACRA2. At default efforts, jixel is 19–35% faster than AVIF, and its files are only 2–6% larger. Comparing higher efforts, jixel 'slow' produces files the same size or slightly smaller than AVIF s5, in the same time or less. And jixel 'extraslow' matches or beats AVIF s0 while being 12–34% faster. But, since ColorVideoVDP gives AVIF more of a file size advantage, it isn't clear-cut. libjxl's default takes 1.3–1.9x as long as AVIF's default, and produces bigger files.
  • Encode memory: JPEG XL (jixel) wins, at lower efforts. At default efforts, jixel uses 37–48% less memory than AVIF, and its files are only 2–6% larger. jixel 'slow' uses about the same memory as AVIF s5 to produce a similar file size, but 'extraslow' uses ~9x the memory of AVIF s0. libjxl's default uses ~2x the memory of AVIF, and its higher efforts use 6–7x.
  • Decode time: AVIF wins. JPEG XL takes 2–2.4x as long to decode at medium quality. But the gap closes at very high quality, where jixel's files decode about as fast as AVIF in Firefox and Safari, and ~25% faster in Chrome.
  • Higher effort is less worth it for AVIF on this image. AVIF's s5 gets you 3–6% smaller files for 3–4x the encode time.
  • Higher effort is worth it for jixel, up to 'slow'. 'slow' gets you 6–14% smaller files than its default 'fast', for 3.5–4.3x the encode time. 'extraslow' saves another 2–9%, but takes 9–14x longer than 'slow' and uses ~9x the memory.
  • Higher effort isn't worth it for libjxl. libjxl's maximum effort gets you 1–2% smaller files than its default, but takes ~12x longer.
  • AVIF's 4:2:0 mode doesn't help. The files are the same size as 4:4:4.

Lossy - weasel

I've played with both codecs a lot, and for photos, what I've shown so far is representative, where AVIF beats JPEG XL. However, every now and then there's an outlier, such as this image.

  • File size: JPEG XL wins. At default efforts, the best JPEG XL image is 4–11% smaller than the AVIF image for the same quality. libjxl is the better JPEG XL encoder at medium/high quality, and jixel at very high quality. At maximum efforts, jixel 'extraslow' is 12–27% smaller than AVIF s0. This time, ColorVideoVDP agrees.
  • jixel's 'medium' setting is a big step up on this image. Its files are 11–25% smaller than AVIF's default, and 4–18% smaller than AVIF s0, the maximum effort.
  • Encode time: JPEG XL (jixel) wins. At default efforts, jixel is 28–38% faster than AVIF, and produces files that are around the same size or smaller. At higher efforts, it's even more one-sided: jixel 'medium' is 37–65% faster than AVIF s5, and produces files 9–23% smaller. libjxl's default takes 1.5–2.2x as long as AVIF's default.
  • Encode memory: JPEG XL (jixel) wins. At default efforts, jixel uses 14–24% less memory than AVIF, and produces files that are around the same size or smaller. AVIF s0 uses even more memory (~1.55x its default), and still produces bigger files than jixel 'medium', which uses 41–55% less memory than AVIF s0. libjxl's default uses 2.4–3.6x the memory of AVIF, and the maximum efforts of both JPEG XL encoders use 10–18x.
  • Decode time: AVIF wins, except in Chrome at higher qualities. JPEG XL takes 1.3–1.9x as long to decode in Firefox and Safari. In Chrome, JPEG XL takes ~1.25x as long at medium quality, but from high quality upwards, it's 11–36% faster than AVIF.
  • Unlike the previous images, jixel images aren't particularly slow to decode in Safari. In fact, they decode slightly faster than libjxl images.
  • Higher effort isn't really worth it for AVIF on this image. AVIF's s5 only gets you 1–3% smaller files for 3–4.5x the encode time. s0 gets you 7–8% smaller files, but takes ~50x as long.
  • Higher effort is worth it for jixel, up to 'slow'. 'medium' gets you 6–14% smaller files than its default 'fast', for 1.9–4.1x the encode time. 'slow' gets you 9–19% smaller files than 'fast', for 3.9–6.5x the encode time. 'extraslow' saves another 5–9%, but takes 8–18x longer than 'slow' and uses ~11x the memory.
  • Higher effort isn't worth it for libjxl. libjxl's maximum effort gets you 1–6% smaller files than its default, but takes 11–14x longer.
  • AVIF's 4:2:0 mode doesn't help. The files are the same size as 4:4:4.
  • All three encoders produce lower SSIMULACRA2 scores for this image. As in, with the same encoder quality value, they score lower. It seems like the encoders are pretty happy smoothing out some of the background noise, which seems fine.

Lossy - screenshot

Ok, let's try something other than a photo:

The results of the benchmark:

  • File size: AVIF wins, by a lot. At default efforts, the best JPEG XL image is 73–89% larger than the AVIF image at medium/high quality (jixel), and 29–44% larger at very high quality (libjxl). At maximum efforts, the best JPEG XL image (jixel 'extraslow' with patches) is 23–73% larger than AVIF (s0).
  • libjxl can't produce anything between SSIMULACRA2 ~45 and ~78 at its default effort (~45 and ~75 at higher efforts). jixel doesn't have this problem.
  • jixel's patches feature helps a lot with this image, but it's off by default. With patches, jixel's files are 10–29% smaller at its higher efforts, from SSIMULACRA2 ~65 upwards. Patches have no effect at jixel's default 'fast' setting.
  • Encode time: AVIF wins. At default efforts, jixel is ~50% faster than AVIF, but its files are 70–98% larger. libjxl's default takes 1.7–1.8x as long as AVIF's default, and still produces much bigger files.
  • Encode memory: AVIF wins. At default efforts, jixel uses ~40% less memory than AVIF, but again, its files are much larger. libjxl's default uses 1.2–2.2x the memory of AVIF. jixel 'extraslow' with patches gets closest to AVIF's file sizes, but uses ~7.5x the memory of AVIF's default.
  • Decode time: AVIF wins, by a lot. Taking the faster of the two JPEG XL encoders' output, JPEG XL takes 2–3.8x as long to decode, depending on browser and quality.
  • Higher effort is more worth it for AVIF on this image. AVIF's s5 gets you 6–13% smaller files for 2.5–3x the encode time, and s0 gets you 16–23% smaller files for 30–45x the encode time.
  • Higher effort is worth it for jixel, as long as you enable patches. 'medium' with patches gets you 11–44% smaller files than its default 'fast', for 3–4x the encode time. 'slow' with patches saves another 7–16% for ~1.7x the time of 'medium'. 'extraslow' with patches saves another few percent, but takes ~5x longer than 'slow' and uses ~4.5x the memory.
  • Higher effort is harmful for libjxl. At very high quality, libjxl's maximum effort produces files 33–40% larger than its default effort, and takes ~7x longer.
  • AVIF's 4:2:0 mode helps at higher qualities. At SSIMULACRA2 70 and above, it produces files 7–18% smaller than 4:4:4. At lower qualities, it makes no difference.
  • All three encoders produce higher SSIMULACRA2 scores for this image. Opposite to the weasel image. But, this seems correct. At SSIMULACRA2 70 the artifacts are pretty noticeable around the Firefox logo (to me, anyway), so I'd go with 80, which is where the codecs lean anyway.

Some developers, especially those who've been around as long as I have, seem to think things like screenshots need to be delivered in a lossless format. But I think this is because they're used to formats like JPEG and WebP (and seemingly JPEG XL) which handle this kind of image badly, whereas lossy AVIF handles them really well.

Lossy - illustration

In many cases, for largely flat-colour images, you'd use SVG. But sometimes that isn't possible (it's a screenshot), or undesirable, such as this case. This image, also from Unsplash, is still 1.1 MB as a compressed and minified SVG. This is due to the complexity of the shaded areas. As a result, a raster format wins on file size and decode/draw time.

At quality 50 in the demo above, the jixel has some pretty severe colour blocks, e.g. on the cat's face. Again, it makes me question the metric a bit.

The results of the benchmark:

  • File size: AVIF wins, by a lot. At default efforts, the best JPEG XL image (jixel) is 32–65% larger than the AVIF image up to SSIMULACRA2 80, and ~90% larger beyond that. At maximum efforts, the best JPEG XL image (jixel 'extraslow') is 22–55% larger than AVIF (s0) up to SSIMULACRA2 80, and more beyond that. ColorVideoVDP agrees, and puts the gap at 27–58%.
  • libjxl performs particularly badly with this image. At default efforts, it's 71–106% larger than AVIF.
  • Lossy AVIF can't reach SSIMULACRA2 90 with this image. Even at its maximum quality setting, it tops out at ~87, so the 90 example above isn't a totally fair comparison. JPEG XL can reach 90, but the result is 8–50% larger than JPEG XL's lossless version, so you may as well use lossless.
  • Encode time: AVIF wins. At default efforts, jixel is 35–44% faster than AVIF, but its files are 32–65% larger, which is a bad trade. jixel needs its 'slow' setting to get within 7–27% of AVIF's default file size, which takes 2.4–2.6x as long as AVIF. libjxl's default takes 1.3–2.3x as long as AVIF's default, and produces much bigger files.
  • Encode memory: AVIF wins. At default efforts, jixel uses ~37% less memory than AVIF, but again, its files are much larger. jixel 'slow' uses about the same memory as AVIF, and 'extraslow' uses ~12x. libjxl's default uses 1.5–3x the memory of AVIF.
  • Decode time: AVIF wins, by a lot. JPEG XL takes 2–4.6x as long to decode at medium/high quality, narrowing to 1.6–2.1x at very high quality.
  • Higher effort is more worth it for AVIF on this image. AVIF's s5 gets you 5–8% smaller files for ~2.7x the encode time, and s0 gets you 14–21% smaller files for 35–48x the encode time.
  • Higher effort is worth it for jixel, up to 'slow'. 'medium' only gets you 4–13% smaller files than its default 'fast', but 'slow' gets you 19–24% smaller files (up to 36% at very high quality), for ~4x the encode time. 'extraslow' saves another 2–3%, but takes 8–9x longer than 'slow' and uses ~11x the memory.
  • jixel's patches feature doesn't help with this image. Unlike the screenshot, the file sizes are identical with and without it, but it does increase encode time and memory.
  • Higher effort isn't worth it for libjxl. libjxl's maximum effort gets you 3–9% smaller files than its default, but takes 10–13x longer.
  • AVIF's 4:2:0 mode doesn't help. SSIMULACRA2 rates it slightly worse than 4:4:4, and ColorVideoVDP rates it slightly better (~3–4%).

Lossy - alpha

So far, all the images have been mostly opaque. What about an image with transparency?

There are a few things that make this one different:

  • jixel always encodes the alpha channel losslessly. It doesn't have a setting for alpha quality. This puts jixel at a big disadvantage with this image. AVIF by default compresses the alpha channel lossily, tracking the main quality setting. libjxl compresses the alpha channel losslessly by default, so watch out for that. For this benchmark, I set libjxl's alpha quality to the same as the main quality.
  • SSIMULACRA2 scores fully-transparent pixels as perfect, so with lots of transparency, the scores will be higher than they would be for an opaque image.

The results of the benchmark:

  • File size: AVIF wins, by a lot. At default efforts, the best JPEG XL image (libjxl) is 31–44% larger than the AVIF image for the same quality. At maximum efforts, it's 61–70% larger. Due to its lossless alpha, jixel is 1.9–3.1x the size of AVIF at default efforts.
  • Encode time: AVIF wins. libjxl's default takes 1.2–1.35x as long as AVIF's default, and produces much bigger files. jixel's default is ~2x faster than AVIF, but its files are around 2–3x the size.
  • Encode memory: AVIF wins. libjxl's default uses 2.4–2.7x the memory of AVIF's default, although in absolute terms, that's ~120 MB vs ~46 MB. jixel's default uses about the same memory as AVIF, but again, its files are much bigger.
  • Decode time: AVIF wins. libjxl's output takes 2–3x as long to decode at medium quality, narrowing to 1.4–1.8x at very high quality. jixel's output decodes faster than libjxl's, and at very high quality it's around the same speed as AVIF. Although, it's slow in Safari at lower qualities, taking ~2.4x as long as AVIF.
  • Higher effort is worth it for AVIF. AVIF's s5 gets you 4–7% smaller files for 2.3–2.9x the encode time, and s0 gets you 10–15% smaller files for 24–29x the encode time.
  • Higher effort barely helps jixel on this image. 'extraslow' only gets you 5–8% smaller files than its default 'fast', for 21–26x the encode time.
  • Higher effort is harmful for libjxl. libjxl's maximum effort produces files up to 8% larger than its default effort, and takes ~6.5x longer.
  • AVIF's 4:2:0 mode is harmful. It produces files 4–27% larger than 4:4:4, and can't get beyond SSIMULACRA2 ~79.

Lossy - small

The images so far have been a similar resolution. Are the results different with smaller images?

Not really. The results are pretty similar to the full-size F1 image:

  • File size: AVIF wins, by about the same margin. At default efforts, the best JPEG XL image is 13–18% larger. At maximum efforts, the gap is slightly bigger than the full-size image, at 12–20%.
  • Encode time & memory: AVIF wins, although libjxl's default is now around the same speed as AVIF's default. jixel's default is ~2x faster than AVIF, but its files are 13–18% larger, and this time it uses ~30% more memory. In any case, at default efforts, everything encodes in under 50ms, using under 40 MB, so these differences don't really matter at this size.
  • Decode time: AVIF wins, by a similar margin, but again, everything decodes in a few milliseconds.

Lossy - large

What about an 18 megapixel image?

Mostly the same story as the regular F1 image, but with a few differences:

  • File size: AVIF wins at medium/high quality, by more than the regular image. At default efforts, the best JPEG XL image is 18–46% larger, and at maximum efforts, 28–54% larger.
  • But JPEG XL catches up at very high quality. Around SSIMULACRA2 85, libjxl's default matches AVIF's default, and at maximum efforts, jixel 'extraslow' is ~6% smaller than AVIF s0. jixel 'slow' is 10–13% smaller than AVIF's default from SSIMULACRA2 80 upwards.
  • Encode time: AVIF wins at medium/high quality, but JPEG XL (jixel) wins at very high quality. AVIF's default takes ~0.5 seconds, whereas libjxl's default takes 1–2 seconds. jixel's default is ~2x faster than AVIF, but at medium/high quality its files are 29–57% larger. At very high quality, that drops to 6–12% larger, which seems like a reasonable trade-off for the speed. And jixel 'slow' produces files around the same size as AVIF s5, while being 33–41% faster.
  • Encode memory: AVIF wins, and the costs are now significant. AVIF's default uses ~560 MB. libjxl's default uses ~2.5 GB at medium/high quality. jixel's default uses ~35% less than AVIF, and 'slow' uses ~18% less than AVIF s5. At maximum efforts, libjxl uses 5–6.4 GB, and jixel 'extraslow' uses 6.8–8.3 GB.
  • Decode time: AVIF wins at medium/high quality, by a similar margin to the regular image. jixel images take over 5x as long as AVIF to decode in Safari at those qualities. At very high quality, JPEG XL decodes faster than AVIF in Chrome.

Quality stability

Another criticism I've heard levelled at AVIF is that its quality is unreliable. As in, a single quality setting is great for some images, but no good for others, meaning for automatically encoded images, you need to pick a higher quality setting to be safe, resulting in larger file sizes than necessary for some images.

For the images I've looked at so far, I encoded them with avifenc -q 50, cjxl -q 70, and jixel at quality 80, and recorded the resulting SSIMULACRA2 scores:

ImageAVIFJPEG XLJPEG XL (jixel)
s6 4:4:4s0 4:4:4s6 4:2:0s0 4:2:0e7e10fastextraslow
F174.675.973.473.879.278.075.377.3
F1 (small)70.973.568.469.575.673.970.672.6
F1 (large)77.077.476.276.680.179.277.578.9
People70.772.069.870.569.670.369.172.3
Red panda67.568.867.067.269.568.469.272.3
Weasel56.757.256.056.356.655.454.959.4
Screenshot80.280.482.381.986.587.676.680.3
Illustration78.679.776.877.980.378.575.477.7
Alpha67.369.863.664.969.466.966.869.6

The behaviour of the codecs here seems really similar. All produce a range of SSIMULACRA2 scores, and they all go higher or lower for the same images.

And I think this is ok? Like, all three go for a lower quality for the Weasel picture, which removed noise without reducing quality of the image subject. With the screenshot, all go for a higher quality, which seems justified to my eyes.

Obviously this is a small sample size, but I haven't seen anything that suggests using a single quality setting is a risk, for any of the three codecs.

Also see Halide Compression's deeper dive into this.

Progressive

Both AVIF and JPEG XL are capable of showing something progressively as the image loads. Chrome and Firefox support progressive JPEG XL, whereas only Chrome supports progressive AVIF. So, and I say this through gritted teeth, this next demo is best viewed in Chrome (I have a Firefox patch in progress):

  • AVIF: 11.5 kB of 151.3 kB
  • JPEG XL: 11.5 kB of 166.0 kB

This is the 18 megapixel image again, so if you're viewing this on a device with a smaller screen, you're not getting the full effect.

Progressive AVIF and JPEG XL work very differently. With JPEG XL, you can opt into the data being stored in such a way that a decoder can render low resolution versions, before it brings in the higher resolution tiles. jixel recently (like, yesterday at time of writing) added support for this too. It's called "progressive DC", and it's the most important kind of progressive rendering for JPEG XL.

With AVIF, progressions work more like frames of a video. You provide up to four frames, so that's three phases before the final image. Again, like a video, each frame can use data from the previous frame as an optimisation. An advantage of this is you can choose exactly how those frames look. In the example above, I went with a single preview frame: a 1/8th resolution version, with a bit of blurring. But, your preview frames can be completely different images if you really want.

There's a lack of data when it comes to progressive images and user experience, so these are just my reckons:

If you're on a bad connection, data doesn't arrive gradually like some throttling tools will have you believe. It tends to come in bursts. So, there's little benefit to progressive rendering on smaller images, and I'm not sure that many progressive phases is particularly useful.

I think that it's nice to have a representative preview for images that may take some time to load. I've experienced this personally, where a site or app has given me something clearly not-loaded, but with enough detail to know what's coming. That's useful to me, as I can decide things like, if it's worth waiting for the whole thing. This is also why I think things like BlurHash are cute, but useless. They're not a representative preview, so there's little a user can do with the information provided.

The preview frame I chose for the AVIF is enough to tell it's an open-wheel race car. If you know F1, you'll know what team it is. You might even know where the photo was taken. And it delivers that in ~11.5 kB.

The JPEG XL gets a BlurHash-like preview on screen in ~2 kB, but it takes until ~63 kB to match the preview quality of the AVIF. After that, it continues to improve gradually, providing a more detailed and higher quality image as more data arrives.

However, I'm not convinced this is a good user experience, as it presents the user with something they may mistake for fully-loaded, but poor quality. For example:

Here's a crop of the JPEG XL, 80% loaded. It seems reasonable that someone would look at that, and just assume I took the picture badly, leaving parts out of focus. You could probably convince me that blurring the ApeCoin logo is a societal benefit, but it still feels like a suboptimal user experience.

AVIF's system gives me more control, but it's also more complex.

Progressive overhead

I encoded the large F1 image with and without progressive rendering enabled, adjusting the quality setting to keep the SSIMULACRA2 score around 70:

  • With libjxl, progressive is basically free. In fact, it produced files 6–10% smaller. At default effort, it was also faster to encode, and used less memory. Decoding was ~10% faster in Firefox, but 12–20% slower in Chrome and Safari.
  • With jixel, it depends on the effort. For jixel, I used progressive DC, with the centre of the image loading first. At jixel's default ('fast'), progressive made the file 12% bigger. At 'extraslow', it made the file 10% smaller, and 11% faster to encode, although it used 17% more memory. Decoding was barely affected, other than being 7–9% slower in Chrome.
  • But ColorVideoVDP is less kind to progressive jixel. It rates jixel's progressive files slightly lower than the non-progressive versions, whereas it rates libjxl's progressive files slightly higher. Taking that into account, progressive jixel at 'extraslow' is only ~2% smaller than non-progressive, and at 'fast' it's around 18% bigger.
  • Regular progressive AVIF has a bigger cost when it comes to encoding. It uses around twice the memory, and at default effort (s6), it takes twice as long to encode. At s0, encode time is only 9% longer, but the file is 14% bigger. And this is pure encode time - it doesn't factor in how you might generate the preview frame, e.g. adding some blur.
  • Regular progressive AVIF is 29–44% slower to decode in Chrome, although the difference is smaller in other browsers (6–15%). Since Chrome is the only browser that supports rendering the progressive frames, that might explain it. Although Firefox and Safari are still having to decode the progressive frame, as the main image builds upon it.
  • With AVIF, making the final frame a keyframe fixes the decoding overhead, at the cost of a longer encode time. At maximum effort, it results in a smaller file too, only 3–5% bigger than the non-progressive version. You could call this cheating, as there's now no 'progression' in terms of data - it's just two images in one. But, if it's a better user experience, who cares?
  • Even with the overhead, progressive AVIF wins on decoding time, and usually on file size. The progressive AVIFs are 152–181 kB. jixel's 'extraslow' progressive JPEG XL is in that range at 166 kB, but jixel's default produces 252 kB, and libjxl's progressive files are 233–242 kB. The AVIFs decode 1.6–6x faster, with the biggest gap in Safari, which is particularly slow to decode jixel's files.

Lossy - HDR

Let's get back to lossy compression, but this time HDR! There are a few ways to encode HDR images, and we're going to look at two.

Perceptual Quantization (PQ) tells each pixel what to be. Each value maps to an absolute brightness, up to 10,000 nits, with the curve shaped so the data is spent where our eyes are most sensitive. If the image asks for colours and brightnesses the display can't show (no consumer display can hit 10,000 nits), the browser/OS has to tone-map it down, and how well that's done varies between platforms.

Gain maps are different. The main image is a regular SDR image (usually), and alongside it is a 'gain map', which applies a brightness and colour change to each pixel to achieve the HDR version.

This has two benefits:

  1. The same image will look exactly as you want on an SDR display - the gain map is simply ignored.
  2. If the display supports HDR, but maybe not as much as the image needs (for example, the screen is at low or maximum brightness), the browser/OS can apply as much of the gain map as it can handle.

CSS and <picture> let us serve an SDR or PQ image to the user via the dynamic-range media query, but then you're relying on tone mapping in cases where the browser signals HDR support, but it doesn't support the full range.

Although the JPEG XL format supports gain maps, no browser handles them, and the encoding story is messy.

AVIF supports gain maps, and you encode them by providing two images: the SDR image and the HDR image. It uses those to calculate and encode the gain map.

Your browser/screen doesn't support HDR, so the PQ images will look bad.

At medium brightnesses, on an HDR screen, the gain map and PQ images look the same. At maximum brightnesses, on my Pixel 10 Pro and 2024 MacBook Pro, the PQ images get a bit blown out, and the gain map image looks better. At low brightness on my Pixel 10 Pro, again the gain map image looks better.

This is because, at extreme brightness levels, the device doesn't have the full HDR headroom to display the image, and gain maps can handle these cases better. So, although gain maps seem to add a lot to the file size, I think it's worth it.

Maybe there's a future where tone mapping gets much better, and the advantage of a gain map becomes less significant.

Regular SSIMULACRA2 only understands SDR, so for this image, the scores come from an experimental HDR mode in fast-ssim2. That means these scores can't be compared to the SSIMULACRA2 scores elsewhere in this article. ColorVideoVDP handles HDR natively, and here it's modelling a 1,500 nit HDR monitor.

The two metrics disagree a lot with this image. And they don't factor in the benefits of gain maps, so treat these results as a comparison between the PQ variants:

  • File size: JPEG XL wins, but the metrics disagree on how much, and with which encoder.
    • According to HDR SSIMULACRA2, jixel's default ranges from 8% smaller to 3% larger than PQ AVIF's default, and libjxl is 5–33% larger. At maximum efforts, jixel 'extraslow' is up to 15% smaller than PQ AVIF s0.
    • According to ColorVideoVDP, libjxl is the clear winner. At default efforts, it's 28–53% smaller than PQ AVIF, and 17–37% smaller than gain map AVIF. At maximum efforts, it's up to 23% smaller than the best AVIF, but jixel 'extraslow' is roughly level with it, ranging from 12% smaller to 14% larger.
  • The metrics also disagree about gain maps. HDR SSIMULACRA2 says gain map AVIF is 31–38% larger than PQ AVIF, but ColorVideoVDP says gain map AVIF is 11–25% smaller than PQ AVIF.
  • Encode time: JPEG XL (jixel) wins. At default efforts, jixel is ~2x faster than PQ AVIF, and produces files around the same size or smaller, depending on the metric. libjxl's default is 5–47% slower than PQ AVIF, but given ColorVideoVDP's view of the file sizes, that's a reasonable trade. Gain map AVIF is the slowest, as it's calculating a gain map, then essentially encoding two images: ~3x slower than PQ AVIF.
  • Encode memory: JPEG XL (jixel) wins. At default efforts, jixel uses ~30% less memory than PQ AVIF. libjxl's default uses ~1.9x the memory of PQ AVIF, and gain map AVIF uses ~1.55x. At maximum efforts, libjxl uses ~6x the memory of PQ AVIF's default, and jixel 'extraslow' uses ~10x.
  • Decode time: AVIF wins. PQ and gain map AVIF decode at around the same speed. JPEG XL takes 1.2–1.7x as long to decode in Chrome, although it's slightly faster at the top of the quality range. In Safari, it takes 1.6–3x as long. Firefox is particularly slow with these HDR JPEG XL images, taking ~4x as long as AVIF at all qualities. We're working on it.
  • Higher effort is worth it for AVIF. For gain map AVIF, s5 gets you 9–12% smaller files for ~1.9x the encode time, and s0 gets you 14–20% smaller files for ~11x the encode time. For PQ AVIF, s0 gets you 14–19% smaller files, for 17–20x the encode time.
  • Higher effort is worth it for jixel, up to 'slow'. 'slow' gets you 7–32% smaller files than its default 'fast', for ~3.8x the encode time. 'extraslow' saves another 1–9%, but takes ~10x longer than 'slow' and uses ~8.5x the memory. jixel only has a few data points for this image, so these numbers are rough.
  • Higher effort isn't worth it for libjxl. libjxl's maximum effort gets you 2–7% smaller files than its default, but takes ~15x longer.

It will be interesting to see how JPEG XL handles gain maps once they're fully supported.

By the way, these are signs that indicate whether my cats are inside or outside, and the little display indicates how long until their next automated feed.

JPEG transcoding

JPEG XL allows you to convert an existing JPEG to JPEG XL without additional loss. I tested this with the F1 image at a range of sizes and JPEG qualities:

  • The JPEG XL is 9–20% smaller than the JPEG. The saving grows with resolution, from ~9% at 400 pixels wide to ~20% at 4,900 pixels wide. The saving is slightly smaller at higher JPEG qualities. libjxl and jixel produce practically identical file sizes.
  • For small images, JPEG XL is slower to decode than JPEG, taking 1.4–2.7x as long, depending on the browser. But it's just a millisecond or two.
  • For larger images, JPEG XL is faster to decode in Chrome and Safari. From ~1,000 pixels wide, it matches JPEG, and at the largest sizes, it decodes in around half the time. In Firefox, it ends up around the same speed as JPEG.
  • jixel's output is slightly slower to decode than libjxl's in Chrome and Firefox, by up to ~25%. But again, these are small numbers, at most a few milliseconds.

Of course, if you have the original, you'd be way better off creating a proper new lossy version with AVIF or JPEG XL, but it's pretty cool that the JPEGs end up smaller, and sometimes faster to decode. Although, I suppose this is just pointing to a missing optimisation in browser JPEG decoders.

Lossless

It is very rare to need lossless images on the web. As we've already seen, even for things like screenshots and illustrations, lossy AVIF handles them really well. Ironically, this page is one of the outliers, because I wanted to show the compressed versions vs their lossless counterparts. Thankfully, JPEG XL is great at lossless!

The results:

  • File size: JPEG XL wins. At default efforts, libjxl produces the smallest files for every image except the illustration, where WebP matches it. WebP is 9–17% larger for photos, and AVIF is 33–122% larger. At highest efforts, libjxl and jixel are within a few percent of each other, WebP is 5–44% larger, and AVIF is 29–176% larger.
  • Encode time: JPEG XL wins. libjxl's default takes 0.1–0.5 seconds, which is faster than WebP's default, and similar to AVIF's default, while producing smaller files than both.
  • Encode memory: JPEG XL wins or WebP wins, depending on the image. WebP's default uses around half the memory of libjxl's default (55–110 MB vs 110–250 MB), but libjxl's files are smaller. For photos, WebP can't match libjxl's default file size even at its maximum effort, which uses more memory than libjxl's default. For the screenshot, they use around the same memory, but libjxl's file is smaller. The illustration is the exception: WebP's maximum effort produces a file 20% smaller than libjxl's default, using the same memory.
  • Decode time: WebP wins or JPEG XL wins, depending on the image. WebP decodes photos in 17–39 ms in all browsers, and the screenshot in ~5 ms. libjxl's output takes 1–2.2x as long in Chrome, 1.4–2.7x as long in Firefox, and 2.1–3.2x as long in Safari. But for photos, JPEG XL's smaller files more than make up for this on a first load, unless the connection is really fast. For the screenshot and illustration, WebP's decode advantage wins. Lossless AVIF is particularly slow to decode, taking up to 4x as long as libjxl's output in Chrome.
  • Lossless AVIF is bad. It's significantly larger than the others, and slower to decode.
  • jixel's 'medium' setting is the sweet spot for file size. It beats libjxl's default on every image (0.5–23% smaller), and is within 2–3% of the best result for photos, taking 0.4–0.7 seconds. But it uses 470–670 MB of memory, and its output is slower to decode than libjxl's, particularly in Safari.
  • jixel's 'slow' and 'extraslow' output is very slow to decode for photos. ~200–290 ms, compared to 22–95 ms for libjxl's output. That's 2–13x slower, depending on the browser. It's also slow for the alpha image, at 80–123 ms compared to 14–27 ms for libjxl's output. This doesn't happen with the screenshot and illustration. This means, for my use-case on this page, I'd avoid it.
  • libjxl's maximum effort is very slow. It produces files 2–7% smaller than its default for photos, and ~25% smaller for the screenshot and illustration, but takes 9–46 seconds, which is 50–180x as long as its default. jixel gets the same results in a couple of seconds.
  • jixel's default 'fast' setting isn't great for lossless. It's the fastest encoder by far (40–80 ms), but its files are 16–32% larger than libjxl's default for photos, and ~80% larger for the screenshot and illustration.
  • jixel's patches feature helps a bit with the screenshot (4–9% smaller), but makes no difference to the illustration.

Animation

Animated image formats are dumb and animated JPEG XL is no exception. Video formats are better at video. They're built for it. They're optimised for it. Not just in terms of encoding, but also in terms of playback.

I encoded a short clip, and the animated AVIF was 725 kB, and the JPEG XL was 2.17 MB for the same ColorVideoVDP score (9.5).

The JPEG XL is super noisy compared to the AVIF, because each frame is encoded individually, and arttifacts land in different places per frame.

AVIF is basically the keyframe format of AV1 video, with a bunch of still-image-specific optimisations thrown in. Animated AVIF however, is pretty much just a repackaging of AV1 video. You can convert an AV1 MP4 to AVIF and back losslessly - it's just a container change. That's why it performs so much better, because it's built for motion.

However, animated AVIF plays like shit in Chrome and Safari. It's fine in Firefox, so this is purely an implementation problem. But… <video muted autoplay> is right there.

The interesting detail of animated AVIF is that it supports transparency, whereas AV1 video doesn't. But really, that just highlights that it's dumb that AV1 didn't support transparency.

No, I'm not adding a demo. I don't want it on my server. Use video formats for video.

And that's it!

I already gave you my conclusions at the start, so I guess I'm ready to hit publish.

I'm a little nervous about this, because discourse around JPEG XL can get a little… weird. I see a lot of conspiracy theories about Google trying to 'silence' JPEG XL, even though one of the leads of the project worked at Google, as part of a team that were working on JPEG XL. That same leader claimed that the post-COVID layoffs wouldn't have happened if companies simply used JPEG XL. I was recently called a racist for asking about JPEG XL use-cases. This kind of thing just doesn't happen with other technologies and standards I follow. So, urm, I'm kinda bracing for impact here.

My first version of this article was very negative about JPEG XL, but then I heard about jixel, and honestly, I'm really excited about it. There's serious competition between AVIF and JPEG XL now, and whoever wins, we'll all benefit from better image compression.

Read the whole story
emrox
6 hours ago
reply
Hamburg, Germany
Share this story
Delete

Why has Shopify dropped React Native?

1 Comment

Before we start: given this article is about native mobile development, I want to offer my 2021 ebook, ‘Building Mobile Apps at Scale: 39 engineering challenges’, for free to all readers. (normally costs $20). The book remains relevant on the challenges to solve for large-scale mobile applications, and lists the technologies covered below in this article, Kotlin Multiplatform included.

Claim your free copy here

This offer is valid until Friday, 2 October. On checkout, simply select “The ebook: PDF & EPUB versions of the book.” No credit card required! Feel free to share the link with colleagues who build mobile apps, or work on them.

Recently, Shopify dropped the bombshell announcement that Native is now the future of mobile development at the e-commerce platform. This triggered a lot of reaction in the mobile development community, on the scale of the stir Shopify generated when it originally moved over to React Native six years ago.

But times have changed, and we all know the agent of change this time: AI agents! This latest generation of models has improved in coding capability to the extent that Shopify no longer is satisfied with React Native (RN), as it was only last year.

This article looks into what led here, why Shopify made the change, and also the broader mobile ecosystem. We cover:

  1. Why Shopify chose React Native in 2020. Android apps took too long to build, first and foremost, and it was nice to have consistent iOS and Android apps.

  2. Still happy five years later. Shopify moved all six apps over to RN and most things were going well, and the 2020 move was seen as a good move.

  3. Why ditch React Native now? AI is now very good at writing mobile code as well as backend code, while React Native adds abstractions that native does not. Shopify rewrote the Shop app to native in just 12 weeks(!!). Additional details from the Shopify mobile team.

  4. Haven’t we seen this back-and-forth before? Airbnb did something similar by adopting React Native in 2016 before moving back to native just two years later. The underlying reason was the same: performance.

  5. Going native since 2019. Moving to native used to take a long time, and Notion has been at it for seven years. When Notion finishes the process, a fair question will be whether a native editor actually slows down their web iteration speed, with or without AI.

  6. Kotlin Multiplatform (KMP) would like a word. KMP allows the writing of business logic in Kotlin and sharing it across iOS and Android while each platform stays native. It’s a technology that feels relevant, and could become more popular with AI.

  7. What’s next? It’s easier to write native iOS and Android apps than before, but that’s also true for React, Flutter, and KMP apps.

Let’s rewind to 2020, when Shopify decided to go all-in on React Native (RN). Back then, mobile was a very important channel for the platform, with seven out of 10 customers using it on mobile devices. As Head of Engineering, Farhan Thawar wrote at the time:

“Each quarter, the majority of buyers purchase on mobile (with 71% of our buyers purchasing on mobile in Q3 of last year [in 2019]). Black Friday and Cyber Monday (together, BFCM) are the busiest time of year for our merchants, and buying activity during those days is a bellwether. During this year’s BFCM, Shopify merchants saw another 3% increase in purchases on mobile, an average of 69% of sales.”

With mobile so important, it’s no surprise the company built native mobile applications for iOS and Android; after all, native mobile gives full control of a platform’s capabilities, integrations, and performance, so why bother with cross-platform technology?

It turns out one reason was the delay in launching native apps on Android, which Shopify struggled with, along with other companies.

During the pandemic at the start of the decade, the live conversations app Clubhouse was a massive hit after launching in March 2020. The app was iOS-only for the first 14 months as the team was unable to release an Android app. And at the end of the year, usage absolutely exploded:

  • December 2020: 600,000 global installs (downloads) (source)

  • January 2021: 3.5 million global installs (source)

  • February 2022: 8 million global installs (source)

Clubhouse app: voice chat blew up with Covid-19. Source: TechCrunch

Despite all that popularity, Clubhouse was unavailable on Android at the time!

It took 14 months to launch a beta version of Clubhouse on Android, and by the time it did in May 2021, the platform had passed its peak a few months earlier and usage numbers were by then trending down. It took until July 2021 to roll out the Android Clubhouse app globally – 16 months after the iOS version – by which time it was too late to catch the growth wave, and that spelt doom for the promising business.

How did this happen? There were a few main reasons:

  1. Android is more complex to build well for than iOS: more device types to support, and much older OS versions to support with different APIs.

  2. Getting good performance on low-end Android phones was hard.

  3. I also wonder if Clubhouse’s team was engineering for a scale that never materialized. Perhaps they tried to build the “perfect” app to launch globally, but missed the growth wave, meaning the app ended up “over-engineered” compared to a different version that could’ve been released sooner, in a more timely fashion that benefitted the business.

Building a native Android app from scratch – even with an existing iOS app as a blueprint – was a challenge during the 2010s and early 2020s. Clubhouse is an extreme case of a business which went wrong, partly due to a sluggish Android launch.

But customer habits could have played a part: at the time, most US startups prioritized iOS apps and features first because in the US, the most valuable users were increasingly found on iOS. Prioritizing iOS made business sense for companies with mainly US customers, but not for ones with a global footprint, where regions like Asia featured mainly Android smartphone users.

Shopify’s first positive experience with React Native was in 2018 with the Shop app (then called ‘Arrive’). The company summarized (emphasis mine):

“At the end of 2018, we decided to rewrite one of our most popular consumer apps, Arrive (which is now Shop app) in React Native. Arrive is no slouch; it’s a highly rated, high performing app that has millions of downloads on iOS. It was a good candidate because we didn’t have an Android version. Our efforts would help us reach all of the Android users who were clamoring for Arrive. It’s now React Native on both iOS and Android and shares 95% of the same code. We’ll do a deep dive into Arrive in a future blog post.

So far, this rewrite resulted in:

  • fewer crashes on iOS than our native iOS app

  • an Android version launched

  • a team composed of mobile + non-mobile developers.”

Moving over to React Native was one of the easiest ways to speed up the rollout of Android versions of new apps. In 2020, when Shopify announced the move, that was the stated intention:

“Our popular Shopify Ping app (now called Shopify Inbox) which has enabled hundreds of thousands of customer conversations is currently only iOS. In 2020, we’ll be building the Android version using React Native out of our San Francisco office and we’re hiring.”

Last year, Shopify declared it was satisfied with the move to RN in a recap article, ‘Five years of React Native’. The summary was quite the victory lap:

“To recap, we decided to switch to RN for three main reasons:

  • Write it once - Stop building the same features twice, once on iOS and once on Android

  • Talent portability - Enable devs to work fluently across iOS, Android, and Web

  • Ship more value - Spend more time delivering value to users instead of chasing feature parity

We’re happy to share that our transition has been quite successful:

  • Not having to build the same features twice has given us a step change in productivity

  • Engineers are able to work across web and mobile allowing teams to do more with the same number of people and unlocked new growth opportunities

  • Maintaining feature parity between iOS and Android has become a non-issue, freeing up capacity to ship a lot more value

  • Our apps are blazing fast (<500ms screen loads) and stable (>99.9% crash-free sessions)

  • We continue to leverage native wherever it is the best tool for the job, giving us the best of both worlds

Over the past 5 years, we have migrated all our apps to React Native. Instead of using a one-size-fits-all approach to do so, each team chose when and how to migrate their app. This allowed them to continue shipping features while also aligning with our strategy of leveraging RN.”

They weren’t kidding: in five years, six mobile apps were migrated to React Native:

App migrations to React Native

Shopify’s engineering team said they loved a lot about React Native. Mustafa explained:

  • “Speed. RN apps are fast. We’ve achieved sub-500ms (P75) screen loads in the Shopify app. We’ve achieved similar performance in all our apps. Just like native, you have to apply good patterns and techniques to eliminate performance bottlenecks.

  • Hot reloading. This was one of our biggest pain points with native. Given the size of our code bases, it took several minutes for even the most trivial changes to be compiled and run on an emulator/physical device. This wastes time and breaks developer flow. React Native’s hot reloading completely eliminates this problem.

  • TypeScript is awesome as the “shared” language for devs. TypeScript has become ubiquitous, and we’ve seen great success with developers transitioning between React web and React Native.”

There were also some new challenges:

  • Worse debugging: “Debugging in React Native is flaky and configuring it correctly in VS Code takes some work. iOS and Android on the other hand have powerful debugging capabilities that just work”

  • Native code and devs still important. You want native code for performance-intensive code, certain animations, long-running background jobs and specialist APIs (like home screen widgets).

  • More third-party libraries. “The React Native framework is not as comprehensive compared to Native, so you end up having to use more third-party libraries.”

But how quickly things can change in tech…

There was widespread surprise this month when Shopify announced that Native is now the future of mobile at Shopify, meaning that RN is suddenly history.

From Head of Mobile, Mustafa Ali (emphasis mine):

“In January 2025, I wrote that the future of React Native was bright and that Shopify planned to keep investing in it. That was true based on what we knew then. React Native was working well for us, and it remains an excellent framework. But since then, coding models have gotten dramatically better, and for our apps and our team, building the same feature in Swift and Kotlin no longer carries the cost it used to.

Native still means building and maintaining software on two platforms, that cost has not disappeared. What changed is that agents can now do enough of the implementation, translation, testing, and review work that it’s no longer the deciding factor it was in 2020.

React Native apps can be fast. Ours are. We are making this change because agents have reduced the advantages of sharing implementation, while the advantages of building for each platform remain. Native keeps us closer to platform capabilities and first-party tooling, with fewer framework and dependency layers between our code and the platform.”

Of course, there have always been pros and cons in choosing native over cross-platform. Pre-AI, this is how it looked:

Native vs React Native development, observed across these four dimensions

What’s changed is that AI coding agents are now much more capable at generating iOS and Android code.

There’s no doubt that fully native apps are well-built and always more efficient than those built on a cross-platform interpreter like React Native. Nonetheless, RN has come a long way to be much more performant – with one additional abstraction layer between RN code and native code – especially since the New Architecture of 2024, Let’s visualize how it works:

How React Native renders views on iOS and Android

React Native’s core philosophy is to minimize updates done on the UI using expensive rendering operations. So, RN maintains a React Host Tree of visual elements on a screen (think a “logical tree”) which transform into a Host View Tree, the representation on iOS and Android. React itself cleverly minimizes re-rendering operations, which saves on compute resources.

As mentioned above, a well-written native app naturally outperforms a React Native version. The easiest way to see why this is, is to sketch what happens with native apps rendering, compared to with React Native:

React Native vs native rendering

React Native can theoretically be more performant, in the unlikely event the Native version has a poorly written structure, or if UIKit / Jetpack Compose or any other native library is inefficient enough for this to happen! But native has many fewer abstraction layers.

One common question since Shopify moved to native is how come the Shop app had not moved over to the New Architecture to improve things like startup performance. I reached out to Head of Mobile, Mustafa Ali, about that. He told me:

“We actually moved other big Shopify apps to the New Architecture. Shopify and Point of Sale were both migrated, and I believe we were one of the first large teams outside Meta to do it.

We got some performance gains with this migration, but they were modest. App startup got about 10% faster on Android and 3% on iOS, and some complex screens actually got slower until we tuned them.”

The team has written in detail about the move to New Architecture. Nonetheless, being on native code provides a lot more opportunities to improve performance; after all, you can now control the complete rendering stack! Heck, you could also throw away the Apple-built system libraries like UIKit or ComposeKit and build more performant ones. Shopify doesn’t plan to go this far, but Head of Engineering, Farhan Thawar, told me they expect performance wins:

“I think we’re just at the beginning of how much better these native apps will be. I expect they will get faster – like with better startup time and more responsive interactivity – as we work through the codebase post migration.”

One benefit of React Native was that it wasn’t necessary to have both an iOS engineer and an Android engineer. But with AI, one engineer can build software on both platforms, Mustafa Ali told me:

“One cultural benefit of switching to RN in the past is that we don’t have a separate iOS and Android team, unlike most native shops.

We have one team, and the same developer builds a feature on both iOS and Android. This keeps the context in one place and there’s no communication overhead.

With LLMs making it easy to contribute code outside of your main expertise, I expect this to become more common in the coming months.”

Farhan put it even more simply:

“Basically, React, as the shared language, is now replaced by the English language, thanks to LLMs.”

Another downside of going native is having two codebases to maintain, which tend to diverge from each other in behavior, features, and bugs. But with AI, Shopify doesn’t see this as a big problem any more. Mustafa:

“Maintaining two separate codebases was a big deal before LLMs, but not anymore.

Agents are quite good at porting a feature from Android to iOS or the other way around. They are also good at comparing the two implementations over time. They can spot gaps in the code and in side-by-side testing, and fix them.”

Shopify is also building a shared verification test suite to validate the two apps as identical. Again, from Mustafa:

“We now have one shared test suite that validates business logic implemented in both Swift and Kotlin. This means that a feature can’t ship until it passes the exact same tests on both platforms. That logic runs headless on desktop, which gives agents a very fast feedback loop to iterate on.”

Smart! It also confirms my sense that verifying code is becoming very important for working with agents, as is building dedicated verification layers.

Let’s return to the comparison table and update it for how native + AI changes the equation for Shopify:

Native vs React Native development, with more capable AI agents

My personal sense is that the downsides of native development disappear with AI tools getting so good, and that Shopify now has full control over the performance and architecture of its native apps.

With the decision to go all-in on native with AI, the e-commerce business is committing to migrate all of its apps. With AI, it should be faster than the migration to React Native was. Mustafa, again:

“We are going to migrate all our mobile apps to Swift and Kotlin using AI throughout the process.

Shop has already shipped as a fully native app in just 12 weeks, the Shopify app is underway, and the rest will follow soon. We’re moving quickly, but not by lowering the bar.

Every rebuild must meet or exceed the performance, stability, accessibility, and product quality people expect today. This isn’t just the same apps rewritten in different languages. We’re rebuilding them so both humans and agents can understand, test, and change them quickly.”

Best of luck to the team with this migration! I’m looking forward to hearing about what they learn with this move.

Read the whole story
emrox
7 hours ago
reply
I guess development time got down with AI, even when running agents in parallel to implement that feature on both platforms. On the other side, that time saved will now be spent on QA since every platform has it's quirks and it still needs to be confirmed everything works well on both. With RN I would trust it more when it's implemented it runs fine on both platforms
Hamburg, Germany
Share this story
Delete

Props Are Not a Design System

1 Share

Most UI kits let you pass any style as a prop — radius, size, color, or a wall of Tailwind classes, whatever you need. That flexibility is exactly the problem: it turns every component call into a one-off styling decision, and no two buttons end up the same. Let’s talk about practices that prevent building an inconsistent, unmaintainable code base that won’t scale.

The Red Circle (1970)

Look at this button built with Tailwind for example:

<button
  className="
    inline-flex items-center justify-center gap-2
    whitespace-nowrap rounded-md text-sm font-medium
    ring-offset-background transition-colors
    focus-visible:outline-none focus-visible:ring-2
    focus-visible:ring-ring focus-visible:ring-offset-2
    disabled:pointer-events-none disabled:opacity-50
    bg-primary text-primary-foreground
    hover:bg-primary/90
    h-10 px-4 py-2
    dark:bg-primary dark:text-primary-foreground
    dark:hover:bg-primary/90
    sm:h-9 sm:px-3
    md:h-10 md:px-4
    lg:text-base
    [&_svg]:pointer-events-none
    [&_svg]:size-4
    [&_svg]:shrink-0
  "
>
  Save changes
</button>

You can do the same thing with pure HTML too:

<button
  style="
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    white-space: nowrap;
    border-radius: 0.375rem;
    font-size: 0.875rem;
    line-height: 1.25rem;
    font-weight: 500;
    transition: color 150ms, background-color 150ms, border-color 150ms,
      text-decoration-color 150ms, fill 150ms, stroke 150ms;
    background-color: var(--primary);
    color: var(--primary-foreground);
    height: 2.5rem;
    padding: 0.5rem 1rem;
  "
>
  Save changes
</button>

Both examples do the same thing: the style is decided at the call site, instance by instance, instead of being named once and reused. That’s what I mean by inlining - the mechanism doesn’t matter, whether it’s a style attribute, style props, or a wall of utility classes.

Aside from the obvious technical issues of page size and caching, there are a few more important foundational problems:

  1. you cannot ensure visual consistency, even within a single file
  2. such code has no semantics, so you can’t tell what it means
  3. such code has no system behind it, so it is difficult to maintain - even with an LLM you’ll wait a long time, because this error-prone style requires many iterations to correct

To address these problems, we need to stop deciding style at the call site and start defining it once, as a reusable, named unit of the interface.

The implementation depends on the stack, but most projects today build on component-based UI kits, so that’s what the examples below will use. Each component will be built from variants and modifiers.

Use variants

A variant is the self-contained style that completely describes all the visual aspects of a component, given a semantic name.

You can use a variant like this:

<Button variant="primary">Save changes</Button>
<Button variant="secondary">Cancel</Button>

In pure HTML with BEM methodology, it would look like this:

<button class="Button Button__variant_primary">Save changes</button>
<button class="Button Button__variant_secondary">Cancel</button>

A variant must contain all implementation details of the visual - inner geometry, colors, typography, everything. It must not expect its styles to be supplemented by anything external, whether that’s a global reset, a stray font-family, or any other in-place styling.

A variant defines the semantic unit of the user interface. That’s what makes a design system observable: you can see exactly which variants of a component exist and consistently update all of them at once.

Use modifiers

When you use a UI kit on a large project with a rich visual design, you may be tempted to write something like this:

<Button
	variant="primary"
	radius="xl"
	fontSize="xl"
	fontWeight={500}
	fontFamily="fancy"
>
	Buy
</Button>

This is the same call-site styling problem again, just typed: the props override the variant from outside, so it no longer defines the button’s final look. Worse, those overrides don’t track the variant — update the variant or introduce a new color scheme, and they silently fall out of sync.

If we need a variation of a view, we can implement a modifier instead, to keep things manageable. A modifier is part of the component’s API, at the same level as the variant - not an escape hatch around it.

For example, we can use variant to set the visual style of a button and add a modifier size to control its geometry:

<Button variant="primary" size="xl">Buy</Button>

If we find ourselves applying many modifiers at once, that’s a signal to introduce another variant instead:

<Button variant="fancy-primary" size="xl">Buy</Button>

This is the core discipline: variants and modifiers aren’t just two features of a UI kit, they’re a rule for where a styling decision is allowed to live, and when a recurring combination of modifiers has earned its own name.

The implementation example in vanilla CSS with a BEM-style modifier:

.button__size_xl {
	border-radius: 12px;
	font-size: 3rem;
	font-weight: 500;
	/* ... */
}

CSS modules for Mantine UI:

.root {
	&[data-size="xl"] {
		border-radius: 12px;
		font-size: 3rem;
		font-weight: 500;
		/* ... */
	}
}

ChakraUI theme:

export default defineConfig({
	theme: {
		semanticTokens: {
			colors: {
				// ...
			},
		},
		recipes: {
			button: defineRecipe({
				variants: {
					size: {
						xl: {
							borderRadius: "12px",
							fontSize: "3rem",
							fontWeight: "500",
						},
					},
					variant: {
						accent: {
							// ...
						},
					},
				},
			}),
		},
	},
});

Same rule, three different technologies - variants and modifiers work regardless of the tools you use.

Design the system

This approach can be applied to almost any UI kit, even one that wasn’t designed with this discipline in mind.

If you follow it, you’ll eventually end up with a design system even if you didn’t set out to build one - you’ll naturally be forced to identify the reusable units of your interface. Your code will stop being a pile of one-off styling decisions and start being a system.

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

Next.js applications, powered by Vite: introducing Vinext 1.0 | Cloudflare Blog

1 Share

When we launched Vinext in February, it was the result of an audacious week-long AI-driven experiment to see how far one engineer, and a stack of tokens, could get to replicating the NextJS framework backed by Vite.

In the seven months since that experiment, Vinext has grown into a framework that our customers trust and run in production for high-traffic, dynamic applications.

Today we are announcing the release of Vinext 1.0, the latest step on our journey to make it possible to deploy Next.js apps anywhere. Vinext lets you take any Next.js application, whether it was built for the Pages or App Router, and make it portable to be deployed to any web platform, including the Cloudflare Workers free plan, Netlify, or AWS Lambda.

Vinext 1.0 brings with it sweeping improvements to compatibility, stability, and caching behaviors, and sets the project up for the long term. There’s never been a better time to take your Next.js project and convert it to Vinext; just run npx vinext check and npx vinext init.

Graduation to 1.0

On release Vinext was promising, but it was incomplete. Since then, we’ve spent a lot of time both improving App Router compatibility and expanding that to Pages Router apps — which we’ve learned many customers are longtime fans of, with large applications that are complex to migrate. We didn’t want Vinext to be a tool that only worked for people using the latest App Router features.

Our focus has been on adopting both these routers, and watching our test compatibility closely, which for most important customer-requested features now surpasses 99%.

Our test compatibility has risen to more than 99%, excluding cache components
Our test compatibility has risen to more than 99%, excluding cache components.

This improvement has been fueled through the community around our GitHub project. As soon as Vinext launched, that community threw it at a wide variety of applications to find the gaps. With their scrutiny, we found challenges not immediately obvious in the test coverage. Vinext needs to act exactly as Next.js behaves. It is not good enough to imitate functions with the same name. Building an alternative import { revalidatePath } is simple enough; the difficulty is in making sure it correctly affects the rendered pages, cache entry, and future requests.

Tracing requests through the application to make sure Vinext responds in the way expected — and replicating not just the API, but the behavior of this machine — was by far the more challenging aspect.

Once we’ve patched problems and brought new features forward, it’s important that we don’t regress, especially if Next.js makes a change. That’s why we’ve also built out our test suite: thousands of focused tests covering core framework behavior across both routers, the development and production server, and the deployment targets of Nodejs and Cloudflare Workers. We also run the Next.js end-to-end test suite against Vinext nightly, giving us a continually moving window on our compatibility, and making sure we immediately become aware of regressions coming from merged changes. Alongside the automated testing, we’ve been working directly with large customers that have Vinext in production to make sure they are not facing issues.

What’s in 1.0

The clearest messages we got from customers using Vinext is that certain Next.js features carry the framework and Vinext didn’t actually need to do everything that Next.js has launched in recent versions to be incredibly useful to them. So we focused on better support where you need it:

  • App Router, Pages Router, and Hybrid applications: We heard from customers that Pages Router was still important, and migrations are not a one-step process. Vinext therefore has support for both routing paths, including React Server Components, Server Actions, API routes, route handlers, middleware, and client-side navigation.
  • The complete page lifecycle: Pages can be rendered in many different ways: on the server, pre-rendered in the build, exported as static assets, or cached with page-level Incremental Static Regeneration (ISR). We’ve made sure that Background and on-demand revalidation work with any output.
  • Caching: Vinext has a shared set of caching functions across the App and Pages Router and the supported runtimes. We have further support for using Cloudflare’s Workers Cache.
  • Observability: Vinext provides Next.js-compatible tracing across both routers, so existing OpenTelemetry and Sentry setups continue to work. On Cloudflare Workers, traces also integrate with native Workers Observability.
  • Next.js ecosystem compatibility:  Vinext implements the public next/* surface and supports common Next patterns for use of authentication, MDX, image optimization, fonts, metadata, environment variables, and more.
  • First-class runtime support for Workers: While Vinext can run anywhere, server code can run in the Cloudflare workerd runtime during development and production, with direct access to bindings such as image optimization and hyperdrive. 

We’ve also made migration part of the framework: it takes two commands to verify that your Next.js install and any modification you have made is compatible, and set up the Vite and deployment configuration while keeping all your previous Next.js project structure.

When we talked to teams about what features were important for them, something stood out. Next.js 16 took a stance that Cache Components were an important part of the future of the framework, and yet most teams that we talked to were not using them and did not consider support a prerequisite to move. Therefore, Vinext today has limited support for the “use cache” directive that drives Cache Components, and though we will continue to improve compatibility there, we’re much more focused on the core priorities above.

Pre-rendering and cache warming

When we first announced Vinext, it supported Incremental Static Regeneration (ISR) after the first request, but it did not yet render pages during the build. Applications use generateStaticParams() and getStaticPaths() to identify pages that should be rendered when building, and they expect page-level ISR to connect those initial responses to background and on-demand revalidation.

Vinext 1.0 supports that lifecycle for both routers. It can prerender App Router and Pages Router routes during the build, serve those responses through page-level ISR, and invalidate them by path or tag. It also supports output: "export" when the result you want is a fully static site.

But this led us to question something: Why should this rendering happen during the build at all?

A site with tens or hundreds of thousands of possible URLs can spend a seriously long time rendering pages that receive little traffic. The build process cannot evaluate the long tail of traffic that most sites experience and therefore cannot focus compute time on the smaller number of more critical pages. Instead you waste hours of time waiting for sequential builds working their way through thousands of pages, long after the most important routes are done.

Cache warming is our solution to this, moving page prerendering from the build machine to Cloudflare’s network. Developers can continue to use Next.js primitives to identify the pages for prerendering, and Vinext can additionally identify high-traffic pages to add to this list. This happens in the background before your site is deployed to production, so that the moment it is, it is ready to serve rapid responses from the Cloudflare cache.

Inside the deployment process, this works by uploading a new Worker version and deploying it to 0% of production traffic, before then requesting pages specifically from that version. This allows the rendering pipeline to work before any real users hit the new deployment. Once the caches have been populated, the deployment can be promoted safely.

What we’re doing next

If the original experiment invented the one-off slopfork, the more consequential part has been how we can keep that process of self-improvement running indefinitely.

The project is now focused on keeping the framework up to date with everything happening upstream. Next.js canary receives new commits every day. Each morning, an agent reviews the changes, fetches diffs, and opens tracking issues for anything that could affect Vinext. Every night, the compatibility matrix is regenerated as we run the Next.js test suite against Vinext.

When one of these tests or issues reveals a gap, agents are now in the position where they can identify the change across both codebases, build a reproduction, port any relevant tests, and propose a fix.

This review has been catching missing cases, unsafe caching behaviors, and differences in the development vs. production servers.

Automation has helped us narrow the stream of activity into a focused set of changes that deserve attention, allowing the maintainers of the project to focus on only the issues that need context of how a process should map onto Vite from the Next.js implementation.

We’re building a software factory for open source at Cloudflare, and you can see what we’re up to on GitHub.

Try it out

Vinext is available for new applications, and existing Next.js projects.

Start a new application today:

npm create vinext-app@latest my-app

Or migrate an existing application:

npx vinext check && npx vinext init

And then deploy it to Cloudflare Workers, with our cache warming:

npx @vinext/cloudflare deploy --warm-cache

Visit vinext.dev for documentation, examples, and the current compatibility matrix. Vinext is open source at github.com/cloudflare/vinext. Issues, pull requests, application reproductions, and feedback are welcome.

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete
Next Page of Stories