Stupidly Tiny Images

How tiny can very tiny banner images get?

Why?

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.

Level 1: PNG

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.

PNG Summary

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?

Level 2: Lossless WebP

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:

  • WebP is mostly talked about as a lossy format, but it can be lossless.
  • WebP's lossless mode is really impressive, and consistently outperforms the most optimized PNGs.
  • WebP's animation support is pretty complete, especially compared to the mess that is GIF and the relatively obscure APNG. Discord recently supports animated WebP by default.

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

WebP Summary

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?

Step 3: JPEG XL

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.

JPEG XL Summary

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.

Mixing Codecs

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.

Animation

For animated images, we have three major options:

  • GIF
  • APNG
  • Animated WebP

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.

My Approach

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.

Conclusion

My final thoughts:

  • For lossless compression, WebP consistently outperformed PNG and GIF, offering meaningful size reductions at a minimal compatibility tradeoff.
  • JPEG XL showed mixed results at this small scale but was able to achieve the smallest file sizes for about half the images using lossless mode. Its slightly lossy palette mode produced the smallest average size overall, but it is still lossy so you may want to use with caution.
  • Using WebP for some images and JPEG XL for others yielded the best overall lossless compression results.
  • For animated images, WebP is clearly superior to both GIF and APNG in terms of file size while still being lossless (lossy WebP is quite good, too; it is a video codec under the hood, after all).
  • Carefully evaluating each image individually with different codecs and settings is the key to achieving the best results.

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>

Help support my open source efforts - a little goes a long way!

Sponsor

© 2025 Gianni Rosato. All Rights Reserved.