omeryanbas.com

Ömer Yanbaş

General Manager, Ticofab Yazılım

PerformanceWeb

When PNG beats AVIF: images made of hard dots

Lossy codecs are built for photographs. On a halftone screen an indexed PNG beat AVIF at every width, and the only way to know is to measure both.

The standard advice is to serve AVIF, fall back to WebP, and keep a JPEG for old browsers. For a photograph that advice is correct and the savings are real. For an image made of hard black dots on white paper, I measured the same picture in eleven encodings and the modern formats lost every one of them, in some cases by a factor of eight. The audit tool still asked for AVIF, because audit tools assume photographs.

What actually happens

AVIF and WebP are lossy transform codecs. They cut the image into blocks, convert each block to frequency coefficients, throw away the coefficients the eye is least likely to miss, and often subsample the colour channels while they are at it. That is a very good model of a photograph, where most neighbouring pixels differ by a little.

A step from white to black is the opposite of that. Reproducing a sharp edge needs the high frequency coefficients, which are exactly the ones the encoder is built to discard. So the encoder has two options and both are bad for us: keep them, and the file grows; drop them, and the edge softens and rings.

A halftone screen is nothing but edges. Every ink dot has a boundary, and at 140 cells across there are tens of thousands of them. The same is true of line art, of a screenshot of text, and of a QR code, where a softened edge is not only ugly but can stop the thing decoding.

An indexed PNG works the other way around. The image becomes a palette of a few colours plus one index per pixel, the rows are filtered so that repeated values turn into zeros, and DEFLATE compresses the runs. Large flat areas and repeated patterns are the best case, not the worst case, and the result is exact.

How to see it

Do not reason about this, encode it. A short script produces every candidate from one source and prints the sizes:

import fs from 'node:fs/promises';
import sharp from 'sharp';

const base = sharp(await fs.readFile('halftone-720.png')).flatten({ background: '#ffffff' }).grayscale();

const candidates = {
  'png indexed 8':  base.clone().png({ palette: true, colours: 8, dither: 0, compressionLevel: 9 }),
  'png indexed 2':  base.clone().png({ palette: true, colours: 2, dither: 0, compressionLevel: 9 }),
  'png truecolour': base.clone().png({ palette: false, compressionLevel: 9 }),
  'webp lossless':  base.clone().webp({ lossless: true, effort: 6 }),
  'webp q90':       base.clone().webp({ quality: 90, effort: 6 }),
  'avif lossless':  base.clone().avif({ lossless: true, effort: 6 }),
  'avif q75':       base.clone().avif({ quality: 75, effort: 6 }),
  'avif q50':       base.clone().avif({ quality: 50, effort: 6 }),
};

for (const [name, pipeline] of Object.entries(candidates)) {
  const buf = await pipeline.toBuffer();
  console.log(name.padEnd(16), String(buf.length).padStart(9));
}

For a 720 by 720 halftone portrait, that produced this:

encodingbytessize
indexed PNG, 2 colours20,25319.8 KB
AVIF quality 50117,452114.7 KB
WebP lossless123,556120.7 KB
indexed PNG, 8 colours132,197129.1 KB
AVIF quality 75195,280190.7 KB
truecolour PNG200,484195.8 KB
AVIF quality 90207,005202.2 KB
WebP quality 75254,772248.8 KB
JPEG quality 90267,724261.4 KB
WebP quality 90337,062329.2 KB
AVIF lossless1,093,1791,067.6 KB

Three things in that table are worth sitting with. Lossless AVIF is more than eight times the size of the PNG that holds the same pixels. WebP at quality 90 is larger than the uncompressed looking PNG and it is also worse, because the dots have softened. The only lossy setting that beats the PNG is AVIF at quality 50, and at that quality the screen has visibly turned to grey mush, which defeats the point of using a halftone at all.

The pattern holds across the whole srcset, and it gets worse as the dot count rises:

widthindexed PNGAVIF quality 75
40049.4 KB51.5 KB
56093.0 KB121.9 KB
720129.1 KB190.7 KB
960205.6 KB277.1 KB

The fix

The real win in that first table is not a codec at all. Going from eight palette colours to two took the file from 129 KB to 20 KB, a bigger saving than any format choice on offer. Eight colours buys a little antialiasing on the dot edges; whether that is worth six times the bytes is a judgement you can now make with a number in front of you.

So the rule is to choose per image class, in the build, rather than per site:

  • Photographs: AVIF, then WebP, then JPEG. This is where the modern formats earn their reputation.
  • Halftone, dithered and two tone images, line art, diagrams: indexed PNG with dithering off, palette as small as the image tolerates.
  • Screenshots of text or a user interface: indexed PNG, or WebP lossless if it measures smaller.
  • QR codes, barcodes and anything a machine reads: indexed PNG only, and never a lossy codec at any quality.

Then pick the widths from the layout instead of from a list of round numbers. Measure the rendered CSS width at each breakpoint, multiply by one and two for device pixel ratio, and generate exactly those. Everything else is dead weight, which is one of the things the responsive images audit is really telling you.

For a halftone there is one more constraint that is easy to miss. Generate every width with the same number of dot cells, so the dots scale with the image instead of getting finer. A halftone whose cells stay a constant number of pixels turns into flat grey at small sizes and loses the whole effect:

for (const width of [400, 560, 720, 960]) {
  const rgba = await halftone(source, { size: width, cells: 140 });  // cells constant, not pitch
  const buf = await sharp(rgba).flatten({ background: '#ffffff' }).grayscale()
    .png({ palette: true, colours: 8, dither: 0, compressionLevel: 9 }).toBuffer();
}

If PNG wins, ship a plain img element with a srcset. A picture element wrapping a single source is noise in the markup and one more thing to keep in sync.

How to check it worked

Make the build refuse to ship a losing encoding. This is four lines and it never goes stale:

const encoded = Object.fromEntries(await Promise.all(
  Object.entries(candidates).map(async ([k, p]) => [k, await p.toBuffer()])));
const best = Object.entries(encoded).sort((a, b) => a[1].length - b[1].length)[0];
if (best[0] !== chosen) throw new Error(`${best[0]} is smaller than ${chosen}`);

Then check the total, because a page is the unit the reader pays in:

find dist/assets/img -type f | xargs ls -l | awk '{ t += $5 } END { printf "%.1f KB of images\n", t / 1024 }'

Expect the modern formats audit to keep complaining. That is fine: an audit is a prompt to check something, not an instruction, and you now have the table that answers it. The same applies to nearly every generic performance rule, which is why measuring before optimising keeps earning its place.

What to watch out for

  • An indexed PNG with more than 256 colours quietly becomes a truecolour PNG and the whole advantage disappears. Assert the colour type of the output, or just assert the byte size against a threshold.
  • Turn dithering off. A dithered quantisation scatters single pixels through flat areas, which destroys the run lengths PNG depends on and can make the file larger than the original.
  • Alpha is a real difference. AVIF handles a smooth alpha channel well; an indexed PNG carries transparency as a palette entry or a tRNS chunk, which is fine for a cut out shape and wrong for a soft shadow.
  • Do not let the server compress a PNG again. It is already DEFLATE, so gzip or brotli over it spends CPU to add a few bytes.
  • Judge a machine readable image by decoding it, not by looking at it. A QR code at quality 90 can look perfect and still fail on a phone camera in poor light.
  • AVIF encoding is slow at high effort settings. If a build encodes dozens of images at every commit, measure the build too.

The useful generalisation here is not about image formats, it is about where a default rule comes from. "Serve modern formats" was derived from photographs because almost all bytes on the web are photographs, and it is good advice for that reason. The moment your image is not a photograph, the derivation no longer applies and you are back to first principles: what does this image actually contain, and which compressor is built for that. It takes ten minutes to encode everything and read the sizes, and the answer is often the format everyone assumes is obsolete. That is also the reason a plain static build keeps winning on weight: every asset decision is made once, visibly, with the numbers on screen.

Questions and answers

Why is AVIF sometimes larger than PNG?
AVIF encodes blocks of pixels as frequency coefficients, which is efficient for the smooth gradients in a photograph and inefficient for a step from white to black. An image made of hard dots is nothing but steps, so the encoder either spends a lot of bits keeping them sharp or blurs them. An indexed PNG stores the same image as palette indices compressed with DEFLATE, which handles long runs of identical pixels extremely well.
Which images should stay as PNG?
Anything with few colours and hard boundaries: halftone and dithered images, line art and diagrams, screenshots of text or user interfaces, logos that are not SVG, and machine readable images such as QR codes and barcodes. For those, an indexed PNG is usually smaller, always exact, and decodes everywhere without a format fallback.
Should I still generate AVIF and WebP sources?
Generate them, measure them, and ship them only for the images where they win. A picture element with a source that is larger than the fallback costs bytes and complexity for nothing. Keeping the encoding decision in the build, per image, means you get the modern format benefit on photographs without paying for it on everything else.
How do I pick srcset widths?
From the layout, not from a list of round numbers. Measure the rendered CSS width of the image at each breakpoint, multiply by one and by two for device pixel ratio, and generate exactly those widths. Widths the layout can never request are dead weight in the markup and in the build.