My friend Haylin introduced me to 88x31 images and I completely fell in love with them. She told me that this weekend I should add some to my site, so I ended up making a couple of custom ones and peering with friends on this site's home page. Fun times.
However, there was a catch. It bothered me that these images were all PNGs and (shudders) GIFs straight out of the 1990s & early 2000s. I get that's the aesthetic, but I don't think it is paramount that we stick with older formats.
I've been involved in the multimedia compression scene donating my time as an AV1 framework and encoder developer for a bit now, and I'd consider myself to be somewhat well-versed in digital multimedia and data compression. Recently in the image compression space, I've been focused on making lossy AVIF performant on larger, mostly photographic images that you'd commonly find on the Web. The 88x31s are thus super far out of my comfort zone, so I was excited to see what I could accomplish with "off-the-shelf" tools in a day or two.
The easiest question to ask ourselves here is "How can we make the existing 88x31s smaller without any compromises whatsoever?"
The answer is simple: PNG optimization. PNG is (in my opinion) an extremely bloated spec compared to formats like QOI that achieve the same goals at much lower complexity. The benefit of this is that there is usually a lot of room for improvement in any standard PNG bitstream you encounter online.
I'm going to ignore animated GIFs and APNG's for now and just look as still images - we can start with twelve 88x31s (archive) pulled straight from matdoes.dev/buttons. We'll cover animations later.
We have ourselves a combined dataset of GIFs and PNGs here.
originals/9.png: 1339 bytes
originals/4.gif: 3058 bytes
originals/5.gif: 845 bytes
originals/7.gif: 2206 bytes
originals/12.png: 389 bytes
originals/3.gif: 1099 bytes
originals/linux_2.png: 1052 bytes
originals/6.png: 825 bytes
originals/firefox_now.gif: 2355 bytes
originals/linux_1.gif: 583 bytes
originals/11.gif: 667 bytes
originals/10.gif: 2523 bytes
Total size: 16941 bytes
Number of items: 12
Average size: 1411 bytes
We're weighing in at an average of 1411 bytes per image. That's not too bad, but we can do better even with the most basic, low-effort PNG encoding.
Using magick -quality 0
we can get some PNGs out of the
static GIFs. Here's the effect on the filesize:
png_unoptim/9.png: 1339 bytes
png_unoptim/12.png: 389 bytes
png_unoptim/linux_1.png: 721 bytes
png_unoptim/firefox_now.png: 2352 bytes
png_unoptim/11.png: 752 bytes
png_unoptim/linux_2.png: 1052 bytes
png_unoptim/10.png: 2458 bytes
png_unoptim/4.png: 3137 bytes
png_unoptim/5.png: 957 bytes
png_unoptim/7.png: 1621 bytes
png_unoptim/6.png: 825 bytes
png_unoptim/3.png: 1213 bytes
Total size: 16816 bytes
Number of items: 12
Average size: 1401 bytes
Even low effort, completely unoptimized PNG beats GIF here on average. GIF sucks, please don't use GIF.
Now let's see what happens if we use ect -9 --mt-deflate
-strip
on the PNGs:
png_ect9/9.png: 1326 bytes
png_ect9/12.png: 369 bytes
png_ect9/linux_1.png: 458 bytes
png_ect9/firefox_now.png: 2050 bytes
png_ect9/11.png: 495 bytes
png_ect9/linux_2.png: 1029 bytes
png_ect9/10.png: 2182 bytes
png_ect9/4.png: 2765 bytes
png_ect9/5.png: 706 bytes
png_ect9/7.png: 1316 bytes
png_ect9/6.png: 808 bytes
png_ect9/3.png: 1006 bytes
Total size: 14510 bytes
Number of items: 12
Average size: 1209 bytes
It doesn't seem like a crazy difference, but considering how little flexibility we have with such tiny images, it is pretty impressive that we're looking at the same exact format and the same image data.
Let's take it a step further with ect -9999 -strip
:
png_ect9/9.png: 1324 bytes
png_ect9/12.png: 368 bytes
png_ect9/linux_1.png: 454 bytes
png_ect9/firefox_now.png: 2048 bytes
png_ect9/11.png: 492 bytes
png_ect9/linux_2.png: 1027 bytes
png_ect9/10.png: 2179 bytes
png_ect9/4.png: 2761 bytes
png_ect9/5.png: 706 bytes
png_ect9/7.png: 1311 bytes
png_ect9/6.png: 806 bytes
png_ect9/3.png: 1003 bytes
Total size: 14479 bytes
Number of items: 12
Average size: 1206 bytes
Look at that, a teensy bit more off. This is pretty much right up against the ceiling of what PNG can do for us without lossy techniques like palette compression.
TL;DR, convert your GIFs to PNGs and run them through ect -9
--mt-deflate -strip
. With images this tiny, it is super fast,
and you'll shave off some bits at zero cost to compatibility or
fidelity. But what if we want to go further?
I know WebP has a bit of a reputation on the Web as having been the format that was shoved down our throats by Google and didn't really ever become a new standard for anything. I've talked in the past about lossy WebP being barely competitive with JPEG despite being far newer, but overall I can't say I dislike WebP as much as the next person. There are some important things to consider with WebP:
I'm going to go in guns blazing with max-effort bruteforce WebP lossless
via cwebp
. I have a script that tries every encoding effort level from 0 to 9 and
picks the smallest one, as effort 9 isn't always the smallest for some
reason. Here's the result:
webp_l/5.webp: 670 bytes
webp_l/linux_2.webp: 700 bytes
webp_l/9.webp: 1114 bytes
webp_l/4.webp: 2456 bytes
webp_l/3.webp: 954 bytes
webp_l/firefox_now.webp: 1724 bytes
webp_l/12.webp: 296 bytes
webp_l/11.webp: 456 bytes
webp_l/10.webp: 1866 bytes
webp_l/7.webp: 1182 bytes
webp_l/linux_1.webp: 370 bytes
webp_l/6.webp: 744 bytes
Total size: 12532 bytes
Number of items: 12
Average size: 1044 bytes
An additional meaningful improvement with a bare compatibility cost and still no loss of fidelity whatsoever. This is the power of WebP's lossless mode. It's almost as significant as the gap between PNG and GIF. WebP is worth using for something like this. It is fair to have hesitation about adopting a slightly less common format, but WebP is supported across all major browsers. If your favorite app doesn't support WebP, it isn't WebP's fault at this point; it is a royalty free format that has its merits, and shouldn't be bastardized an account of its shaky introduction.
But what if you don't care at all about compatibility, and you just want the smallest possible file size without fidelity loss?
JPEG XL is an incredibly cool format. I have plenty of additional JPEG XL coverage on my blog, and sites like the community-run JPEG XL site exist if you'd like more information, but the TL;DR is that JPEG XL is pretty much great at everything. It is a royalty-free, modern format that is designed to be the best of all worlds, and it is a format that I am very excited about.
Unfortunately, Google's Chromium team notoriously rejected JPEG XL from the Chromium browser engine, so it is not supported in Chrome or any browser that uses Chromium. This is a huge bummer. JPEG XL is supported in Safari and throughout the Apple ecosystem, as well as in browsers like Waterfox and Thorium, so it is fair game to use JPEG XL images on your site with an appropriate fallback.
For our purposes, we're just going to look at JPEG XL's lossless
compression with cjxl 0.11.0
. We'll be using the hidden
super high effort mode via cjxl -d 0 -e 11
--allow_expert_options
to see how low we can go:
jxl_l/11.jxl: 417 bytes
jxl_l/10.jxl: 2394 bytes
jxl_l/linux_2.jxl: 740 bytes
jxl_l/12.jxl: 279 bytes
jxl_l/linux_1.jxl: 369 bytes
jxl_l/firefox_now.jxl: 2505 bytes
jxl_l/9.jxl: 1216 bytes
jxl_l/3.jxl: 846 bytes
jxl_l/7.jxl: 1244 bytes
jxl_l/6.jxl: 773 bytes
jxl_l/4.jxl: 3403 bytes
jxl_l/5.jxl: 590 bytes
Total size: 14776 bytes
Number of items: 12
Average size: 1231 bytes
...Hm. Not exactly what was promised.
The reality is that JPEG XL's lossless mode is best with larger images, and images with >8 BPC. Especially for HDR, JPEG XL significantly outperforms high bit depth PNG, and WebP doesn't support >8 BPC. For tiny images like this, it is clearly not the best choice given what we're seeing here.
The one saving grace we have at our disposal with JPEG XL is the super useful tools it makes available to us to do slightly lossy compression. We can do this by lowering the bit depth the encoder uses, or allowing JPEG XL's modular lossless compression to utilize a lossy palette. PNG and WebP are capable of similar tricks as well (like PNG's palette compression mode and WebP's "near lossless" compression), but JPEG XL does it better via cjxl in my opinion.
Through adding --modular_lossy_palette
--modular_palette_colors=0
:
jxl_lossy/11.jxl: 638 bytes
jxl_lossy/10.jxl: 1677 bytes
jxl_lossy/linux_2.jxl: 946 bytes
jxl_lossy/12.jxl: 281 bytes
jxl_lossy/linux_1.jxl: 608 bytes
jxl_lossy/firefox_now.jxl: 1589 bytes
jxl_lossy/9.jxl: 1248 bytes
jxl_lossy/3.jxl: 996 bytes
jxl_lossy/7.jxl: 1403 bytes
jxl_lossy/6.jxl: 773 bytes
jxl_lossy/4.jxl: 1583 bytes
jxl_lossy/5.jxl: 746 bytes
Total size: 12488 bytes
Number of items: 12
Average size: 1040 bytes
This is our best result yet, albeit a lossy one. Visual inpection will allow you determine if this is too lossy for you, in which case it may be best to stick with WebP.
Overall, JPEG XL results seem mixed at this scale, and we've been driven to resorting to slightly lossy compression which isn't a great showing for the codec. However, if you look closely, there are some images that are the smallest with JPEG XL...
jxl_l/11.jxl: 417 bytes
jxl_l/12.jxl: 279 bytes
jxl_l/linux_1.jxl: 369 bytes
jxl_l/3.jxl: 846 bytes
jxl_l/6.jxl: 773 bytes
jxl_l/5.jxl: 590 bytes
Exactly half the pictures were smallest with cjxl. The reference library for JPEG XL (called libjxl) is still pre-1.0, so maybe the bitstream has latent expressivity in its modular lossless mode that we've yet to see realized. The creators of the format have mentioned that peak bitstream expressivity has yet to be reached with libjxl especially as it pertains to lossy encoding, so only time will tell.
By mixing our favorable results from WebP and JPEG XL, we minify our 88x31s to the maximum degree:
mix/11.jxl: 417 bytes
mix/12.jxl: 279 bytes
mix/linux_2.webp: 700 bytes
mix/9.webp: 1114 bytes
mix/linux_1.jxl: 369 bytes
mix/4.webp: 2456 bytes
mix/firefox_now.webp: 1724 bytes
mix/3.jxl: 846 bytes
mix/10.webp: 1866 bytes
mix/6.jxl: 773 bytes
mix/5.jxl: 590 bytes
mix/7.webp: 1182 bytes
Total size: 12316 bytes
Number of items: 12
Average size: 1026 bytes
This gives us, with no fidelity loss, our smallest average size yet. This is a great result, and it shows that inspecting your outputs with compression is almost always worth doing.
For animated images, we have three major options:
I'll spare you the misery - Animated WebP is the best choice for animated images. It's the smallest, and it's supported in all major browsers. GIF is supported everywhere, but it's the largest.
APNG encoding with FFmpeg: ffmpeg -y -i {} -plays 0 {.}.apng
Animated WebP encoding with FFmpeg: ffmpeg -y -i {} -pix_fmt bgra
-c:v libwebp_anim -lossless 1 {.}.webp
Using this image:
du -h upallnight.*
104K upallnight.apng
104K upallnight.gif
72K upallnight.webp
In a bit more detail:
stat -f '%z' upallnight.*
106127 # APNG
106384 # GIF
70712 # WebP
I know single image comparisons are flawed, but I've done more than just this one and the results are consistent. APNG can often make GIFs a bit smaller, but WebP crushes it here. You can optimize APNG further, but there isn't (usually) a compatibility benefit - animated WebP is the way to go.
The 88x31s on my homepage are a combination of a number of different coding techniques. I prioritized getting the maximum fidelity out of JPEG XL, and used lossless WebP as a fallback. My personal 88x31 is a lossy palette JPEG XL, while the JPEG XL community button is a lossy VarDCT JPEG XL. I quantized the bit depth more aggressively with some images at very little fidelity loss, and others I was able to use full lossless. Doing each of your images individually is a great way to get the best results, but can be time consuming.
Everything animated is animated WebP, as animated JPEG XL isn't supported on Safari so the majority of people seeing my JPEG XL animations wouldn't see anything but a still frame.
My final thoughts:
Even for tiny, simple images, modern compression techniques can yield meaningful improvements over legacy formats. It's worth taking the time to experiment with different codecs and settings to find the best balance of quality and file size for your images. You can argue it isn't practical because the images are already so tiny, and you'd probably be right, but this is more about doing it for sport.
So, mess around a bit and see what you can do - it was a lot of fun for me, and as the "compression guy," I can't be caught dead using unoptimized images anywhere :)
Peer with me:
<a href="https://giannirosato.com" target="_blank">
<picture>
<source srcset="https://giannirosato.com/static/images/88x31/gb82_88x31.jxl" type="image/jxl">
<img src="https://giannirosato.com/static/images/88x31/gb82_88x31.webp" alt="gianni" width="88" height="31">
</picture>
</a>