Compare how different image formats load on the web

Image formats on the web

According to the Media chapter in the Web Almanac project, 99.9% of pages generate at least one request for an image resource. It is safe to say that almost all websites contain an image of some sort. Since images are such an important resource - not only because they are present on most of the pages that we visit - but also because they convey information much easier ("A picture is worth a thousand words"), loading them in an efficient manner is of uttermost importance.

Throughout the past years the web has seen an number of image formats, and as of last year - according to the Web Almanac project - the most used image format on the web is still JPEG, followed by PNG, gif, WebP, svg and others. What is really interesting is the fact that WebP, even though it is now a decade old, has reached full browser support in 2020 and it only makes up around 7% of all the image formats used out there today.

There's an amazing article written by Jake Archibald on AVIF where he walks the readers through the ins and outs of AVIF and I also wrote a piece discussing the pros and cons of that image format.

A tool to visualise loading behaviour

All that being said, I wanted to create a tool where I can visualise the perceived load performance of the various image formats and be able to compare them.

The tool is rather simple - it takes an image and splits it up into three equal parts. These three parts are then loaded using the image formats specified by the user via the UI.

But how can the tool achieve all of this "automagically"? How can it load different image formats and split up an image?

I am utilising Cloudinary behind the scenes because (on top of a vast number of things) they can crop images using x and y coordinates as well as load various image formats via the f_ URL modifier.

To achieve the basic logic I have created two helper functions - one will get the height of the image based on the input (which can currently be only set by changing the source code). To "cache" things and avoiding making an extra request to measure the height of the image over and over, the height value is also stored in localStorage keyed off from the src.

const getImageHeight = (src) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = function () {
localStorage.setItem(src, this.height);
resolve(this.height);
};
img.onerror = reject;
});
};

The second helper function is responsible for loading the images according to the format selected by the user. The function accepts a number of parameters:

const loadImage = (src, location, done) => {
const img = new Image();
img.src = src;
img.onload = function () {
position++;
done(location, position);
};
document.getElementById(location).appendChild(img);
};

The position that is referenced in the loadImage function above is referencing a variable that is initiated with a value of 0 and will use a ranking map. This map helps print a load time ranking in the UI (see the sample vidoes below):

let position = 0;
const ranking = new Map();
ranking.set(1, '🥇');
ranking.set(2, '🥈');
ranking.set(3, '🥉');

And now let's see how the loadImage function is being called:

loadImage(
`https://res.cloudinary.com/tamas-demo/image/upload/w_${width}/c_crop,w_${splitWidth},h_${height},y_0,g_west/f_${format}/${publicId}`,
index,
(location, position) => {
document.getElementById(`format-${location}`).textContent += `${ranking.get(
position
)}
`
;
}
);

The src parameter includes the width of the image (which is the total width), a splitWidth which is just a section of the image and the y axis is set to 0. The function's done callback is responsible for injecting the image to the DOM and adding a ranking and a "loading complete" checkmark indicator as well (based on the map previous discussed).

The second and third images would have these values added: x_${splitWidth} and x_${splitWidth * 2}. In the case of an image that's 1200 pixels wide, we'd create 3 400 pixel images and crop them using g_west, x_400 and g_west,x_800 respectively.

In the example below you can see an AVIF, Progressive JPEG and PNG selected. The network connection has also been throttled to "Slow 3G" using Chrome's DevTools.

As expected, the AVIF file loads first, however due to the nature of this format, the entire file is loaded - compare that with both PNG and JPEG. For the progressive JPEG we can actually see a blurry layer loading first. Progressive JPEG first loads a pixelated view and layer after layer the image will become more crisp and sharp.

In the second example we can see WebP, AVIF and JPEG-XL. (Please note that you need to manually enable JPEG-XL support using chrome://flags/#enable-jxl). In this case the AVIF loads first again, followed by JPEG-XL and WebP. Do note that JXL has multiple decoding speeds (cjxl --faster_decoding=3 would make it even faster). When it comes to the percieved load time JXL wins when compared with the other two selected image formats.

If you'd like to learn more about JXL, please read Time for Next-Gen Codecs to Dethrone JPEG. As a side note, I am really excited for JXL because it is a true image format that will have a great impact on the web. Both WebP and AVIF are all formats derived from video codecs and therefore have some shortcomings.

If you're interested in this tool, feel free to contact me, as at this point I do not plan on making it public. I use this tool at various web performance workshops to demonstrate how different image formats perform under various conditions and what the pros and cons are for each.