completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Image formats

JPEG, PNG, WebP, AVIF, JPEG XL and SVG: what each is good at, and how to choose a quality setting. A photo and a screenshot encoded in every format to the same SSIMULACRA2 score, decoded in Chrome, Firefox and WebKit, loaded as an LCP image on a slow link, and an animated GIF turned into video.

By the end, you can
  • 01
    Choose a kind of fileLossy for photos, lossless for screenshots and line art, SVG for drawings, and video for animations.
  • 02
    Compare formats fairlySave each format at the same measured quality, then compare the bytes, not the quality numbers.
  • 03
    Ship new formats safelyOffer AVIF and JPEG XL in a <picture> with a JPEG fallback, and pick a quality with a metric and your eyes.

One photo, five formats

Ravi looks after the Leaf and Kettle tea shop's website. He's getting the spring sale page ready. It needs a big photo at the top. A big photo like that is often called the hero.

When Ravi saves the photo, his photo editor asks two questions. Which format: JPEG, PNG, WebP, AVIF or JPEG XL? And what quality, on a slider from 1 to 100? Does his choice matter?

It matters to Asha. She'll open the page on her phone, on a slow link. The course saved the same photo as PNG and as AVIF. It made each one the hero, and loaded the page in Chrome 5 times each:

The same photo, saved two waysPop out in the code editor (opens in a new tab)JavaScript
// The sale page's photo, saved two ways. Each time is the median of 5 loads, in ms.const asPng = { bytes: 747474, onScreen: 4212 };const asAvif = { bytes: 30149, onScreen: 628 }; console.log(`As PNG: ${asPng.bytes} bytes. The photo appeared at ${asPng.onScreen} ms.`);console.log(`As AVIF: ${asAvif.bytes} bytes. The photo appeared at ${asAvif.onScreen} ms.`);
  • Line 1 is a comment. Times are in milliseconds (ms), thousandths of a second. Each time is the median of the 5 loads. That's the middle one, once they're sorted from fastest to slowest.
  • Lines 2 and 3 are the photo as PNG and as AVIF. Each holds the file's size in bytes, and when the photo appeared.
  • Lines 5 and 6 print As PNG: 747474 bytes. The photo appeared at 4212 ms. and As AVIF: 30149 bytes. The photo appeared at 628 ms.

Same photo, same page, same link. Asha saw the AVIF 3,584 ms sooner, more than three and a half seconds. The photo is the largest thing on the page. So the moment it appears is the page's Largest Contentful Paint, or LCP. How LCP works showed how the browser times it.

So the format matters. That raises new questions. What did the AVIF leave out, to be about 25 times smaller? Would a JPEG have done as well? Can every browser show an AVIF? And where should the slider go?

Ravi has three more images to save. One is a screenshot of the sale's dashboard. One is the shop's logo. The last is a short animation that zooms into the photo. Each kind of image needs a different kind of file. Here is where the lesson ends up:

Each kind of image, and the kind of file it should be. A photo: lossy, AVIF or JPEG XL, JPEG last. A screenshot: lossless, PNG, or lossless WebP. A logo: svg, drawing commands, no pixels. An animation: video, WebM and MP4, not a GIF.Ravi's imageKind of fileWhich filesA photoLossyAVIF or JPEG XL, JPEG lastA screenshotLosslessPNG, or lossless WebPA logoSVGdrawing commands, no pixelsAn animationVideoWebM and MP4, not a GIF
The kind of image decides the kind of file. Each section of the lesson shows where one of these arrows comes from.

A fair race between formats

To find the best format, you have to compare files fairly. That's harder than it sounds. Each format has its own quality slider, and the numbers don't match.

Real-life analogyComparing tea by weight, not by the word on the bag

Two brands of tea sit side by side on a shelf. One bag says "Large". The other says "Family size". The words tell you nothing, because each brand picks its own. So you put both bags on the shop's scale. Then you compare what you pay for the same 100 g.

In real life: "Large" on one bag, "Family size" on the other
In image files: Quality 83 in a JPEG encoder, quality 68 in an AVIF encoder
In real life: The shop's scale, the same for every bag
In image files: One quality test, the same for every file
In real life: The same 100 g of tea
In image files: The same quality score
In real life: The price you pay for it
In image files: The bytes Asha downloads

The course compared formats that way:

  • It took two images. The photo is two parrots from the Kodak image suite, which its host says many sites use to test compression. The screenshot is the shop's dashboard, as Chrome drew it.
  • It saved each image in all five formats. Then it scored every file with a quality test called SSIMULACRA2. At 80, an average person comparing the file with the original, side by side, wouldn't notice a difference.
  • Some formats are lossy: they drop detail you're unlikely to see. For each lossy format, the course kept the lowest quality that still scored 80. So the lossy files look equally good.
  • Chrome, Firefox and WebKit, the engine Safari is built on, each tried to show every file.
  • Chrome loaded the sale page with the photo in each format 5 times. The link was like a slow 4G phone network: a 150 ms round trip and 1.6 Mbps down. Mbps means megabits per second. A round trip is the time for a message to reach the server and for its answer to come back.

Pick an image and a format. See what the course measured:

Playground: two images, five formats
The file
  1. Quality setting83
  2. Size43.6 kB
  3. SSIMULACRA2 score80.2
  4. LCP as the hero, in Chrome700 ms
  5. Chrome 156 decodes ityes
  6. Firefox 155 decodes ityes
  7. WebKit 26.6 decodes ityes
Try it yourself
A photo as JPEG: 43.6 kB

Saved at the lowest quality setting that still scored 80, so it looks as good as the other lossy files. Made with sharp, with mozjpeg. The LCP is the median of 5 loads in Chrome.

Recorded in Chrome 156, Firefox 155 and WebKit 26.6 on Apple M1 Pro. Chrome loaded each hero over a link with a 150 ms round trip and 1.6 Mbps down.
The main idea

The kind of image decides the kind of file. Use lossy for photos, lossless for screenshots and line art, SVG for drawings, and video for animations. Then compare formats at the same measured quality, and send the fewest bytes. Add a fallback for browsers that can't show the newest.

Images and media, in the system design course, plans how a site stores and serves images. This lesson picks the file.

Lossy vs lossless

Why was the PNG so big? Because PNG is lossless: the browser gets back every pixel exactly as it was saved. The PNG standard calls it a format for "the lossless, portable, well-compressed storage of static and animated raster images".

A raster image, like a photo or a screenshot, is a grid of tiny coloured dots: the pixels.

Lossless: every pixel comes back

How can PNG shrink a file and keep every pixel? It packs the pixels with DEFLATE, the method gzip uses. In gzip, Brotli, and Zstandard, that was writing "same as step 3" instead of writing step 3 again. It saves the most when the data repeats.

Start with the photo written out in full. Its host describes these photos as "24 bits per pixel". That's three bytes per pixel, one each for red, green and blue.

The photo, with every pixel keptPop out in the code editor (opens in a new tab)JavaScript
// The photo is 768 by 512 pixels. Each pixel is 3 bytes: red, green and blue.const everyPixel = 768 * 512 * 3;const png = 747474; console.log(`Every pixel written out: ${everyPixel} bytes.`);console.log(`As PNG: ${png} bytes, ${((png / everyPixel) * 100).toFixed(1)}% of that.`);
  • Line 2 works out the size of every pixel written out in full: 768 times 512 pixels, at 3 bytes each.
  • Line 3 is the PNG the course made, in bytes.
  • Line 5 prints Every pixel written out: 1179648 bytes.
  • Line 6 prints As PNG: 747474 bytes, 63.4% of that.

PNG saved about a third. Now the screenshot, saved the same way:

The screenshot, with every pixel keptPop out in the code editor (opens in a new tab)JavaScript
// The screenshot is 1280 by 800 pixels, 3 bytes each.const everyPixel = 1280 * 800 * 3;const png = 27137; console.log(`Every pixel written out: ${everyPixel} bytes.`);console.log(`As PNG: ${png} bytes, ${((png / everyPixel) * 100).toFixed(1)}% of that.`);
  • Line 2 works out the screenshot written out in full. It has more pixels than the photo. Line 3 is its PNG.
  • Lines 5 and 6 print Every pixel written out: 3072000 bytes. and As PNG: 27137 bytes, 0.9% of that.

The bigger image made by far the smaller file. A screenshot has wide areas of one flat colour. It has the same letters again and again. So DEFLATE finds repeats everywhere. A photo is what MDN calls a continuous-tone image. Its colours shade slowly into each other. So neighbouring pixels are seldom exactly the same. A lossless format has to keep every one of those small differences.

Lossy: leave out what the eye barely notices

A lossy format makes a different deal. MDN puts the two side by side. Compression can be lossy, "discarding image data to get much smaller files, or lossless, reproducing the original exactly."

Real-life analogyDescribing a photo over the phone

You've just seen a lovely photo. You call a friend to describe it. You don't read out the colour of every feather. You say: "Two parrots, one of them bright red." Your friend gets the picture in a few seconds. They miss tiny details that they'd hardly have noticed anyway.

In real life: "Two parrots, one of them bright red"
In image files: A lossy file: the picture's shapes and colours
In real life: The colour of every single feather, left out
In image files: Fine detail the encoder throws away
In real life: Your friend getting the picture in seconds
In image files: A small file that arrives quickly

The program that saves a file in a format is its encoder. JPEG is, in MDN's words, "currently the most widely used lossy compression format for still images". Its standard is called ITU-T T.81. It shows where the encoder drops detail:

  1. The encoder cuts the image into small squares of 8 by 8 pixels.
  2. It turns each square into 64 numbers, using a method called the discrete cosine transform. Each number says how much of one pattern the square holds, from smooth to finely striped.
  3. It rounds each of the 64 numbers, using a table of step sizes. The standard calls this table a quantization table. Rounding is where the detail is lost.
  4. It packs the rounded numbers without any further loss.

In the standard's words, the result is "substantial compression" with "high visual fidelity". Fidelity means being true to the original. So the file is much smaller, but still looks like the original. Here is the photo both ways:

The photo, lossless and lossyPop out in the code editor (opens in a new tab)JavaScript
// The photo in bytes: lossless PNG, and a JPEG that scored 80 for quality.const png = 747474;const jpeg = 43633; console.log(`The PNG is ${(png / jpeg).toFixed(1)} times the size of the JPEG.`);
  • Line 2 is the photo as PNG, in bytes.
  • Line 3 is the photo as JPEG, at the lowest quality that still scored 80.
  • Line 5 divides one by the other. It prints The PNG is 17.1 times the size of the JPEG.

That's seventeen times the bytes for a difference an average person wouldn't notice side by side. It's also why the vacuum-sealed parcel in gzip, Brotli, and Zstandard didn't shrink. A JPEG has already been squeezed this way.

Now try the same with the screenshot:

The screenshot, lossless and lossyPop out in the code editor (opens in a new tab)JavaScript
// The screenshot in bytes: lossless PNG, and a JPEG that scored 80 for quality.const png = 27137;const jpeg = 37944; console.log(`The JPEG is ${(jpeg / png).toFixed(1)} times the size of the PNG.`);
  • Lines 2 and 3 are the screenshot as PNG and as JPEG. The JPEG scored 80, like the photo's.
  • Line 5 prints The JPEG is 1.4 times the size of the PNG.

This time the lossy file is the bigger one. MDN's advice: "For screenshots, diagrams, logos, and line art, prefer lossless encoding, as blurring and colored fringes around text and sharp edges are very visible."

Why it matters: save photos in a lossy format. Save screenshots, charts and line art in a lossless one.

Drawings: SVG has no pixels at all

The logo is a third kind of image: a few flat shapes. For those, there's a format that doesn't store pixels.

Real-life analogyA recipe, or a photo of the dish

A friend asks how to make your lemon cake. You could send a photo of the cake, or you could send the recipe. The recipe is a few lines of text. From it, your friend can bake a small cake or a big one. Print the photo as a poster, though, and you'll see the dots it's made of.

In real life: The recipe: a few lines of steps
In image files: An SVG: drawing commands
In real life: Baking a small cake or a big one
In image files: Drawing the logo at any size
In real life: The photo of the cake
In image files: A raster image: a fixed grid of pixels
In real life: The dots on the poster
In image files: Pixels stretched bigger than the file holds

SVG, Scalable Vector Graphics, is the recipe. MDN describes it as a format that "specifies the contents of an image as a set of drawing commands that create shapes, lines, apply colors, filters, and so forth." Here is the shop's whole logo:

The shop's logo, as drawing commandsPop out in the code editor (opens in a new tab)HTML
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">  <rect width="64" height="64" rx="14" fill="#1d6b4f"/>  <path d="M18 26h24v10a12 12 0 0 1-24 0z" fill="#fff"/>  <path d="M42 29h3a5 5 0 0 1 0 10h-3" fill="none" stroke="#fff" stroke-width="3"/>  <path d="M27 21c0-4 4-4 4-8M33 21c0-4 4-4 4-8" fill="none" stroke="#bfe6d4" stroke-width="2.5" stroke-linecap="round"/></svg>
  • Line 1 starts a drawing. Its viewBox sets up a grid of 64 by 64 units to draw on.
  • Line 2 draws a green square with rounded corners that fills the grid.
  • Line 3 draws the cup as a path, a list of pen moves. M18 26 moves the pen, and h24 draws 24 units across.
  • Line 4 draws the cup's handle, and line 5 two curls of steam.
  • Line 6 ends the drawing. Choose Edit & run to see it drawn.

The file is 410 bytes. As a 128 by 128 PNG, the logo was 2,165 bytes, more than five times as many. MDN calls SVG ideal for "images which can be accurately drawn at any size." A PNG isn't. Shown any bigger, it has to stretch pixels it doesn't have. The SVG is simply drawn bigger.

Why it matters: logos, icons and simple diagrams belong in SVG. They stay tiny and sharp on every screen.

WebP and AVIF

So the photo should be lossy. But JPEG isn't the only lossy format any more. Two newer ones were built to beat it:

  • WebP. web.dev: "Google originally developed WebP as a lossy image format to supersede JPEG, one that was able to produce files smaller than a comparable-quality image file encoded as JPEG." To supersede is to replace.
  • AVIF. web.dev: "AV1 Image File Format (AVIF) is an encoding based on the open source AV1 video codec." A codec is a method for compressing and decompressing. AV1 was made for video.

MDN notes that both can also be lossless, have see-through parts, and hold animations.

The photo on the scale

Which makes the smallest photo? Here is the tea on the scale. Every file below scored 80. JPEG XL, from the next section, is in the race too.

The photo in four lossy formats, at the same scorePop out in the code editor (opens in a new tab)JavaScript
// The photo in each lossy format, at the lowest quality that scored 80: [quality, bytes].const photo = {  JPEG: [83, 43633],  WebP: [89, 47736],  AVIF: [68, 30149],  "JPEG XL": [78, 30141],}; for (const [format, [quality, bytes]] of Object.entries(photo)) {  console.log(`${format}: ${(bytes / 1000).toFixed(1)} kB, at quality ${quality}`);}
  • Line 1 says what each pair holds. That's the quality setting that reached 80, and the file's size in bytes.
  • Lines 3 to 6 are the four formats.
  • Lines 9 to 11 print one line per format, in kB: kilobytes of 1,000 bytes, the way DevTools counts.
  • The first two lines printed are JPEG: 43.6 kB, at quality 83 and WebP: 47.7 kB, at quality 89.
  • The last two are AVIF: 30.1 kB, at quality 68 and JPEG XL: 30.1 kB, at quality 78.

AVIF and JPEG XL tied, each 13.5 kB smaller than the JPEG. WebP came last, bigger than the JPEG. That may surprise you. MDN says lossy WebP images are "on average 25–35% smaller than JPEG images of visually similar compression levels". But an average isn't your image. Measure your own.

The screenshot on the scale

Now the screenshot, saved the same way. It tells a different story:

The screenshot in four lossy formats, at the same scorePop out in the code editor (opens in a new tab)JavaScript
// The screenshot in each lossy format, at the lowest quality that scored 80: [quality, bytes].const screenshot = {  JPEG: [81, 37944],  WebP: [56, 11794],  AVIF: [31, 5872],  "JPEG XL": [48, 18820],}; for (const [format, [quality, bytes]] of Object.entries(screenshot)) {  console.log(`${format}: ${(bytes / 1000).toFixed(1)} kB, at quality ${quality}`);}
  • Lines 3 to 6 are the same four formats, made from the screenshot.
  • The first two lines printed are JPEG: 37.9 kB, at quality 81 and WebP: 11.8 kB, at quality 56.
  • The last two are AVIF: 5.9 kB, at quality 31 and JPEG XL: 18.8 kB, at quality 48.

Here every newer format beat JPEG easily, and AVIF was the smallest. For screenshots, MDN still advises lossless, because a blurred letter is easy to spot. Lossless WebP made 11.4 kB, less than half of the PNG's 27.1 kB.

Now look at the quality numbers. To score 80, AVIF needed 68 for the photo and 31 for the screenshot. JPEG needed 83 and 81. Like "Large" on a bag of tea, a quality number only means something inside its own encoder.

Here is every file the course made, in one picture:

Each format's size: the lossy ones at an SSIMULACRA2 score of 80, PNG lossless. A photo: AVIF 30.1 kB, JPEG XL 30.1 kB, JPEG 43.6 kB, WebP 47.7 kB, PNG 747.5 kB. A screenshot: AVIF 5.9 kB, JPEG XL 18.8 kB, JPEG 37.9 kB, WebP 11.8 kB, PNG 27.1 kB.A photoAVIF30.1 kBJPEG XL30.1 kBJPEG43.6 kBWebP47.7 kBPNG747.5 kBA screenshotAVIF5.9 kBJPEG XL18.8 kBJPEG37.9 kBWebP11.8 kBPNG27.1 kB
Each image has its own scale, so compare bars within one image. The shaded bar is the lossless PNG. The plain bars are the lossy files, all at the same score of 80.

Fewer bytes, sooner on screen

Does a difference of a few kilobytes matter to Asha? Here is the sale page's LCP with the photo in each format:

When the photo appeared, in each formatPop out in the code editor (opens in a new tab)JavaScript
// The photo as the hero in each format, on the slow link: the median LCP of 5 loads, in ms.const lcp = { AVIF: 628, "JPEG XL": 640, JPEG: 700, WebP: 716, PNG: 4212 }; for (const [format, ms] of Object.entries(lcp)) console.log(`${format}: the photo appeared at ${ms} ms`);
  • Line 2 holds each format's LCP. Each is the median of the 5 loads, in ms.
  • Line 4 prints one line per format, from AVIF: the photo appeared at 628 ms to PNG: the photo appeared at 4212 ms.

The two 30.1 kB files came first, then the JPEG, the WebP and the PNG: the order of their sizes. In The four parts of LCP, a heavy photo made the resource load duration longer. That's the time its bytes spend on the way. A bigger order spends longer in the oven. The link's speed tells you how long:

How long the bytes take on the linkPop out in the code editor (opens in a new tab)JavaScript
// The slow link carries 1600 kilobits a second. A byte is 8 bits, so 200 bytes arrive each millisecond.const bytesPerMs = 1600 / 8;const jpeg = 43633;const avif = 30149; console.log(`The JPEG needs ${(jpeg / bytesPerMs).toFixed(1)} ms on the link.`);console.log(`The AVIF needs ${(avif / bytesPerMs).toFixed(1)} ms on the link.`);
  • Line 2 turns the link's speed into bytes per millisecond. 1600 kilobits a second is 1600 bits each millisecond. And 8 bits make a byte.
  • Lines 3 and 4 are the two files' sizes, in bytes.
  • Lines 6 and 7 divide each size by the speed. They print The JPEG needs 218.2 ms on the link. and The AVIF needs 150.7 ms on the link.

That's 67.4 ms apart, and the recorded LCPs were 72 ms apart. So the bytes explain almost all of it. The PNG's bytes alone need 3,737 ms on this link.

Why it matters: on this link, every kilobyte of the LCP image costs Asha 5 ms.

JPEG XL support

JPEG XL tied with AVIF for the photo. Chrome's team writes that "Chrome is shipping decoding support for the JPEG XL (.jxl) image format starting from Chrome 155". Decoding is turning a file back into pixels. The course asked three engines to decode it:

Which engines can show JPEG XLPop out in the code editor (opens in a new tab)JavaScript
// Whether each engine decoded the JPEG XL photo, and the file it took from the <picture>.const engines = {  "Chrome 156": { jxl: true, picked: "/photo.jxl" },  "Firefox 155": { jxl: false, picked: "/photo.avif" },  "WebKit 26.6": { jxl: true, picked: "/photo.jxl" },}; for (const [engine, { jxl, picked }] of Object.entries(engines)) console.log(`${engine}: ${jxl ? "decodes" : "can't decode"} JPEG XL, shows ${picked}`);
  • Lines 3 to 5 record what each engine did. Did it decode the JPEG XL file? And which file did it show from a <picture>? You'll meet that element in a moment.
  • The first line printed is Chrome 156: decodes JPEG XL, shows /photo.jxl.
  • The next two are Firefox 155: can't decode JPEG XL, shows /photo.avif and WebKit 26.6: decodes JPEG XL, shows /photo.jxl.

Firefox 155 can't show it. And Ravi doesn't know which browser Asha uses. MDN's advice: "Because browser support is not yet universal, provide a fallback using the <picture> element." A fallback is a second choice. It's for when the first can't be used.

Real-life analogyA new suitcase that not every airline takes

You've bought a new cabin bag. It's smaller than your old one and holds just as much. But not every airline accepts it yet. So you bring both to the airport, the new one first. At the desk you hand over the first bag this airline takes. The old bag is accepted everywhere, so you're never stuck.

In real life: The new, smaller bag
In HTML: A JPEG XL or AVIF file
In real life: The old bag that every airline takes
In HTML: The JPEG in the <img>
In real life: The airline's list of bags it accepts
In HTML: The formats this browser can decode
In real life: Handing over the first bag the airline takes
In HTML: The browser using the first <source> it supports

A picture with a fallback

Here is the <picture> the course gave each engine:

Newest first, JPEG lastHTML
<picture>
  <source srcset="/photo.jxl" type="image/jxl">
  <source srcset="/photo.avif" type="image/avif">
  <source srcset="/photo.webp" type="image/webp">
  <img src="/photo.jpg" alt="Two parrots" width="768" height="512">
</picture>
  • Line 1 opens the <picture> element. The HTML standard says it "does not display anything". It only offers files to the image inside it.
  • Line 2 offers the JPEG XL file. Its type names the format as a MIME type: image/jxl. A MIME type is the standard name for a kind of file.
  • Lines 3 and 4 offer the AVIF and the WebP the same way.
  • Line 5 is the <img>, the element on the page, with the JPEG that every browser can show.
  • Line 6 closes the picture.

The HTML standard says how a browser chooses. It reads the <source> elements in order. Its rule: "If child has a type attribute, and its value is an unknown or unsupported MIME type, continue to the next child." In plain words: skip any file this browser can't show. If none fits, the standard's example says "the img element will be chosen."

That's what the engines did. Firefox skipped the JPEG XL and took the AVIF. Chrome and WebKit took the JPEG XL, the first in the list. So order matters. The browser takes the first file it can show, not the smallest.

Leave out type, though, and there's a trap. The standard warns: "If the type attribute is not specified, the user agent will not select a different source element if it finds that it does not support the image format after fetching it." So a browser would download the first file. If it couldn't decode it, it would fail and stop there.

Why it matters: give every source a type. And always end with an <img> that every browser can show.

AVIF or JPEG XL?

Which should Ravi try? First, two terms. High-fidelity means very close to the original. Progressive decoding shows a rough version of the whole image early. Then it sharpens it as more bytes arrive. Now Chrome's team: "In general, we recommend trying both AVIF and JPEG XL to get the best results. We expect that JPEG XL is most helpful for high-fidelity or lossless compression, especially of photographic images or in cases in which fine-grained progressive decoding is preferred." MDN notes that AVIF "does not support progressive rendering".

JPEG XL has one more trick, for sites full of old JPEGs. To transcode is to convert a file to another format. MDN says JPEG XL "can also losslessly transcode existing JPEG images, allowing the original JPEG file to be reconstructed." Does it work? The course tried two tools from libjxl. Chrome's post calls libjxl "the C++ reference implementation of JPEG XL". That's the official example code for the format. cjxl makes a JPEG XL file. djxl turns one back.

From JPEG and back, without lossPop out in the code editor (opens in a new tab)JavaScript
// The photo's JPEG from above, recompressed losslessly with cjxl, then turned back with djxl.const jpeg = 43633;const jxl = 38793;const sameBytesBack = true; console.log(`JPEG XL: ${(jxl / 1000).toFixed(1)} kB, ${Math.round((1 - jxl / jpeg) * 100)}% smaller`);console.log(`back to JPEG: ${sameBytesBack ? "the very same bytes" : "different bytes"}`);
  • Line 2 is the photo's JPEG from the WebP and AVIF section, in bytes.
  • Line 3 is the JPEG XL that cjxl made from it.
  • Line 4 records what djxl gave back: exactly the same bytes as the JPEG.
  • Lines 6 and 7 print JPEG XL: 38.8 kB, 11% smaller and back to JPEG: the very same bytes.

Why it matters: a site can shrink its existing JPEGs. None of them looks any different. And each one can still be turned back.

Choosing quality with your eyes and with metrics

Back to Ravi's quality slider. Where should it go? First, what does its number mean?

Real-life analogyThe toner dial on a photocopier

The office photocopier has a dial to save toner. Turn it down a step, and the copies look just the same, but use less toner. Keep turning, and for a while nobody can tell. Then, quite suddenly, the letters start to break up. The copier next door has a dial too, but its 5 isn't the same as yours.

In real life: Turning the dial down
In an encoder: Lowering the quality setting
In real life: Copies that look the same and use less toner
In an encoder: Smaller files that look like the original
In real life: The letters suddenly breaking up
In an encoder: Artifacts appearing
In real life: The copier next door, whose 5 isn't yours
In an encoder: Another encoder's quality scale

The number belongs to the encoder

The JPEG standard leaves the rounding tables to the software. It says: "No default values for quantization tables are specified in this Specification". The course's JPEG encoder is mozjpeg. Its quality number picks a table of step sizes: bigger steps, more rounding, fewer bytes. Its docs say the setting "Constructs JPEG quantization tables appropriate for the indicated quality setting." Other encoders have their own scales. That's why JPEG's 83 and AVIF's 68 both scored 80.

A score for what people see

To know what a setting really did, measure the result with a metric. A metric is a test that gives a number. SSIMULACRA2 compares a file with its original. Its README says the score "correlates to subjective visual quality scores". That means it predicts what people would say.

Its scores talk about artifacts: the marks lossy compression leaves behind. The blurring and coloured fringes MDN warned about for screenshots are artifacts. MDN calls them "side effects of a lossy encoding process in which the lost or rearranged data results in visibly negative effects." Here is how the README describes four scores:

  • 30: "Obvious and annoying artifacts." It calls this "low quality".
  • 50: "Slightly annoying artifacts." It calls this "medium quality".
  • 70: "Artifacts are perceptible, but not annoying." It calls this "high quality".
  • 80: "Distortion not noticeable by an average observer in a side-by-side comparison at 1:1 from a normal viewing distance." It calls this "very high quality".

Here is the photo as JPEG at nine settings, each one scored:

The photo as JPEG, from quality 20 to 95Pop out in the code editor (opens in a new tab)JavaScript
// The photo as JPEG at each quality: [bytes, SSIMULACRA2 score].const ladder = {  20: [10399, 23.1],  30: [14122, 42.1],  40: [17635, 53.3],  50: [20757, 60.7],  60: [24186, 65.8],  70: [29326, 71.4],  80: [39491, 78.1],  90: [61451, 84.3],  95: [92229, 87.5],}; for (const [quality, [bytes, score]] of Object.entries(ladder)) console.log(`q${quality}: ${(bytes / 1000).toFixed(1)} kB, score ${score}`);
  • Lines 3 to 11 are the nine settings. Each holds the file's size in bytes and its score.
  • Line 14 prints one line per setting, from q20: 10.4 kB, score 23.1 to q95: 92.2 kB, score 87.5.
The photo as JPEG at each quality: q20 10.4 kB, score 23.1; q30 14.1 kB, score 42.1; q40 17.6 kB, score 53.3; q50 20.8 kB, score 60.7; q60 24.2 kB, score 65.8; q70 29.3 kB, score 71.4; q80 39.5 kB, score 78.1; q90 61.5 kB, score 84.3; q95 92.2 kB, score 87.5.q2010.4 kB, score 23.1q3014.1 kB, score 42.1q4017.6 kB, score 53.3q5020.8 kB, score 60.7q6024.2 kB, score 65.8q7029.3 kB, score 71.4q8039.5 kB, score 78.1q9061.5 kB, score 84.3q9592.2 kB, score 87.5
Each bar is the photo saved at one quality setting, with its score. Shaded bars scored 80 or more. At the high settings, each step adds more bytes for fewer points.

Read the bars from the top. From q20 to q70, the file grew by 18.9 kB, and the score jumped 48.3 points. It went past "low" and "medium" to "high". From q80 to q90, the file grew by 22.0 kB, and the score rose just 6.2 points. That's the toner dial. At low settings, each step buys a lot. At high settings, each step costs more and buys less.

Then look for yourself

A metric gets you close. Then use your eyes. Here is the red parrot's eye at three settings, next to the original:

The red parrot's eye: the originalThe original, lossless
The red parrot's eye: jpeg quality 90JPEG quality 90: 61.5 kB, score 84.3
The red parrot's eye: jpeg quality 60JPEG quality 60: 24.2 kB, score 65.8
The red parrot's eye: jpeg quality 30JPEG quality 30: 14.1 kB, score 42.1
The red parrot's eye at twice its size. Each crop was decoded from its JPEG and saved without loss, so your browser shows exactly what the JPEG held.

Look closely at the feathers around the eye. At quality 30 they've melted into flat, blotchy patches. The edge of the beak looks blocky. At 60 the feathers are softer than in the original. At 90 it's hard to tell them from the original.

Ravi doesn't need to do this for every photo. Check a few images of each kind your site has. Pick one setting per kind and format. Then let your build tool apply it. Why it matters: the last few points of quality cost the most bytes. And on the LCP image, bytes are time.

Replacing GIFs with video

The last image on Ravi's list moves. It's a 3-second banner that slowly zooms into the photo, 480 by 320 pixels. It's saved as an animated GIF. web.dev warns: "Animated GIFs can be downright huge." The course checked. It made the animation. Then web.dev's commands made two videos from it, MP4 and WebM:

The same animation as a GIF and as videoPop out in the code editor (opens in a new tab)JavaScript
// The 3-second animation as a GIF, and as video from web.dev's commands, in bytes.const gif = 4233576;const video = { MP4: 90010, WebM: 28708 }; console.log(`GIF: ${(gif / 1000).toFixed(1)} kB`);for (const [format, bytes] of Object.entries(video)) console.log(`${format}: ${(bytes / 1000).toFixed(1)} kB, ${Math.round(gif / bytes)}× smaller`);
  • Line 2 is the GIF, in bytes. Line 3 holds the two videos.
  • Line 5 prints GIF: 4233.6 kB.
  • Line 6 prints MP4: 90.0 kB, 47× smaller and WebM: 28.7 kB, 147× smaller.

That's over 4 MB for 3 seconds of a slow zoom. At 200 bytes a millisecond, Asha's link needs 21.2 seconds for the GIF, and 143.5 ms for the WebM.

The 3-second animation: GIF 4,233.6 kB, MP4 90.0 kB, WebM 28.7 kB.GIF4,233.6 kBMP490.0 kBWebM28.7 kB
The same 3-second animation, drawn to scale. Next to the GIF, both videos are barely wider than a line.

Why such a gap? The two store moving pictures differently:

  • A GIF is lossless, with at most 256 colours. MDN explains what comes after the first frame. The GIF stores "a series of images reflecting the parts of the image that change with each frame". In a zoom, the whole picture moves every frame.
  • A video stores changes instead. MDN's video guide: "Compression of video generally works by comparing two frames and recording the differences between them". It can also record that something moved, and how far. That's the "same as step 3" note again, between pictures.

A video that behaves like a GIF

web.dev makes a video act like a GIF with this markup:

A video that acts like a GIFHTML
<video autoplay loop muted playsinline>  <source src="my-animation.webm" type="video/webm">  <source src="my-animation.mp4" type="video/mp4"></video>
  • Line 1 has four attributes. They copy what a GIF does. web.dev: a video with them "plays automatically, loops endlessly, plays no audio, and plays inline". Inline means inside the page, not full screen.
  • Lines 2 and 3 offer the WebM, then the MP4. Like a <picture>, the browser takes the first one it can play. web.dev: "Browsers don't speculate about which <source> is optimal, so the order of <source>'s matters."
  • Line 4 closes the video.

One of web.dev's commands didn't work as given. With ffmpeg 9.0.2, web.dev's WebM command stopped with an error about the GIF's pixel format. A pixel format is the way each pixel's colour is stored. Adding the MP4 command's -pix_fmt yuv420p fixed it:

web.dev's commands, and the fixShell
ffmpeg -i my-animation.gif -b:v 0 -crf 25 -f mp4 -vcodec libx264 -pix_fmt yuv420p my-animation.mp4
ffmpeg -i my-animation.gif -c vp9 -b:v 0 -crf 41 my-animation.webm
# Pixel format 'gbrap' is not widely supported. ...
ffmpeg -i my-animation.gif -c vp9 -b:v 0 -crf 41 -pix_fmt yuv420p my-animation.webm
  • Line 1 is web.dev's MP4 command. -i names the input file, and -pix_fmt yuv420p sets the pixel format of the video.
  • Line 2 is web.dev's WebM command, as given.
  • Line 3 is the start of the error ffmpeg printed for it.
  • Line 4 is the same WebM command with -pix_fmt yuv420p added. That one worked.

All three engines played both videos. Why it matters: turn animated GIFs into video, and check what a copied command really made.

Which format?

Here are the raster formats from this lesson in one table. Each row is one format. Read across: what it keeps, whether it can have see-through parts, and what MDN suggests it for. A pixel's alpha channel is the part that says how see-through it is.

What each raster format can do
FormatWhat does it keep?Can parts be see-through?What does MDN suggest it for?
JPEGLossy only.No. JPEG has no alpha channel.Photos, and as the fallback for them.
PNGLossless.Yes.Screenshots, diagrams and line art, and as the fallback for see-through images.
WebPLossy or lossless: you choose when you save.Yes.Both still and animated images.
AVIFLossy or lossless: you choose when you save.Yes.Both still and animated images, with a fallback.
JPEG XLLossy or lossless: you choose when you save.Yes.Large, high-resolution images, with a fallback.
GIFLossless, with at most 256 colours.One colour can be fully see-through.Simple images and animations. For still images, MDN prefers PNG.

Now sort Ravi's images, and a few more. What kind of file should each one be?

Which format?
  • The shop's logo, a few flat shapes
  • A set of icons for the shop's menu
  • A screenshot of the sales dashboard
  • A chart exported as an image, with thin lines
  • A product photo as the LCP image
  • Customers' photos in a gallery
  • An animated product demo, now a GIF
  • A silent looping background animation
Try it yourself
0 of 8 correct

Sort each image by the kind of file it should be.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "WebP is always smaller than JPEG." No. At the same score, the photo was 47.7 kB as WebP and 43.6 kB as JPEG. MDN's 25–35% is an average over many images.
  • "Quality 80 means the same in every format." No. Each encoder has its own scale. To score 80, the photo needed quality 83 in JPEG and 68 in AVIF.
  • "PNG is the high-quality choice for photos." No. It keeps every tiny difference between pixels, so the photo was 747.5 kB. As the hero, it appeared at 4,212 ms, against 700 ms as a JPEG.
  • "JPEG XL works nowhere yet." No. Chrome 156 and WebKit decoded it. Firefox 155 didn't, so serve it in a <picture> with a fallback.
  • "Turning a GIF into video is one copied command." No. web.dev's WebM command failed as given with this ffmpeg.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upHow much AVIF saves

This is the photo example from the WebP and AVIF section, with one line added at the end. What does that last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// The photo in each lossy format, at the lowest quality that scored 80: [quality, bytes].
const photo = {
  JPEG: [83, 43633],
  WebP: [89, 47736],
  AVIF: [68, 30149],
  "JPEG XL": [78, 30141],
};

for (const [format, [quality, bytes]] of Object.entries(photo)) {
  console.log(`${format}: ${(bytes / 1000).toFixed(1)} kB, at quality ${quality}`);
}
console.log(`AVIF saves ${Math.round((1 - photo.AVIF[1] / photo.JPEG[1]) * 100)}% on the photo`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe top step of the dial

    This is the quality ladder, with three lines added at the end. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // The photo as JPEG at each quality: [bytes, SSIMULACRA2 score].
    const ladder = {
      20: [10399, 23.1],
      30: [14122, 42.1],
      40: [17635, 53.3],
      50: [20757, 60.7],
      60: [24186, 65.8],
      70: [29326, 71.4],
      80: [39491, 78.1],
      90: [61451, 84.3],
      95: [92229, 87.5],
    };
    
    for (const [quality, [bytes, score]] of Object.entries(ladder)) console.log(`q${quality}: ${(bytes / 1000).toFixed(1)} kB, score ${score}`);
    const [bytes90, score90] = ladder[90];
    const [bytes95, score95] = ladder[95];
    console.log(`90 → 95: ${(bytes95 / bytes90).toFixed(1)}× the bytes for ${(score95 - score90).toFixed(1)} more points`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWebP's score

      Use the playground. For the photo saved as WebP, what SSIMULACRA2 score did the file reach?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePNG's score

        Use the playground. What SSIMULACRA2 score did the PNG files reach?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChrome's decoder

          In which programming language is Chrome's JPEG XL decoder written?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhere the eye stops seeing

            Which SSIMULACRA2 score does its README call "visually lossless"?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what kind of image it is, which browsers must show it, and whether the files are being compared at the same quality.

              Image formats quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this screenshot example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The screenshot in bytes: lossless PNG, and a JPEG that scored 80 for quality.
                const png = 27137;
                const jpeg = 37944;
                
                console.log(`The JPEG is ${(jpeg / png).toFixed(1)} times the size of the PNG.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the third line of this screenshot example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The screenshot in each lossy format, at the lowest quality that scored 80: [quality, bytes].
                const screenshot = {
                  JPEG: [81, 37944],
                  WebP: [56, 11794],
                  AVIF: [31, 5872],
                  "JPEG XL": [48, 18820],
                };
                
                for (const [format, [quality, bytes]] of Object.entries(screenshot)) {
                  console.log(`${format}: ${(bytes / 1000).toFixed(1)} kB, at quality ${quality}`);
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7Why can't you compare formats by their quality numbers?

                Choose an answer to see the explanation.

              4. Question 4 of 7A <picture> lists JPEG XL, then AVIF, then WebP, with a JPEG <img>. Which file did Firefox show?

                Choose an answer to see the explanation.

              5. Question 5 of 7A <source> in a <picture> has no type attribute, and the browser can't decode its file. What happens?

                Choose an answer to see the explanation.

              6. Question 6 of 7The course turned the photo's JPEG into a JPEG XL without loss. What happened?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why turn an animated GIF into a video?

                Choose an answer to see the explanation.

              Key takeaways

              • Photos: a lossy format. Screenshots and line art: lossless. Logos and icons: SVG. Animations: video.
              • Compare formats at the same measured quality, then by bytes. A quality number only means something inside its encoder.
              • AVIF and JPEG XL made the smallest photo here. Offer them in a <picture>, each with a type, newest first and a JPEG last.
              • Choose quality with a metric, then check with your eyes. The last few points cost the most bytes.
              • Turn animated GIFs into WebM and MP4, and check what copied commands really make.

              Remember the one-liner.
              The same quality first, then the fewest bytes, with a fallback.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: Responsive images, also in Loading content fast: LCP. The hero here was saved 768 pixels wide and shown 640 wide. The next lesson sends each screen the size it needs, with srcset, sizes and picture.

              CompleteFrontend Clear concepts. Working examples.