Choosing Quality Settings for AVIF, WebP and JPEG

Take a representative sample of real uploads, and for each format binary-search the encoder’s quality setting until a perceptual metric such as SSIMULACRA 2 reaches the same target score as your reference JPEG; use the median of those per-image settings as the production value, and re-run the calibration whenever the encoder library or your upload mix changes.

“Quality 80” means something different in every encoder. libjpeg’s 80, libwebp’s 80 and libaom’s 80 are three unrelated numbers on three unrelated scales, and even the same encoder shifts between versions. Copying settings from a blog post — including this one — gives you either bloated files or visible artefacts on your particular content. This page belongs to responsive image delivery in media processing and delivery pipelines, and its output feeds the FORMATS table in generating srcset variants at upload time.

When to use this approach

  • You serve several formats and want them to look the same, so negotiation never makes one group of browsers see worse images.
  • Your uploads are a specific kind of content — product photos on white, screenshots, faces, food — that differs from generic benchmark sets.
  • You are about to upgrade sharp or libvips and want to know whether the numbers still mean what they used to.

Prerequisites

  1. Node 20+ with sharp 0.33+ — the same version you run in production.
  2. The ssimulacra2 command-line tool (built from libjxl’s tools, or a packaged binary), which prints a score where about 90 is visually lossless, 70 is high quality and 50 is medium.
  3. 200–500 real uploads, stratified by type, resized to your most-served width (the calibration is width-sensitive).
  4. A decision on the reference: most teams pick “JPEG at quality 80 with mozjpeg”, because it has years of acceptance behind it.

Why the numbers are not comparable

Encoder quality setting versus perceptual score for three formats Three curves of SSIMULACRA 2 score against quality setting. To reach a score of 75, JPEG needs quality 80, WebP needs about 76, and AVIF needs about 52. The horizontal target line crosses each curve at a different setting. Same perceived quality, three different "quality" values 90 75 40 target 75 AVIF 52 WebP 76 JPEG 80 q 0 q 50 q 100 Pick the target score once; let the curves tell you each format's setting.
The target is a perceptual score, not a number on an encoder dial; each format reaches it at a different setting.

Implementation

The script below encodes each sample image in each format, binary-searches the quality that meets the reference score, and prints the median setting and median bytes per format.

import sharp from "sharp";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, readdir, readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

const run = promisify(execFile);
const WIDTH = 800;                                   // your most-served width

type Fmt = "jpeg" | "webp" | "avif";
const ENCODE: Record<Fmt, (s: sharp.Sharp, q: number) => sharp.Sharp> = {
  jpeg: (s, q) => s.jpeg({ quality: q, mozjpeg: true }),
  webp: (s, q) => s.webp({ quality: q, effort: 4 }),
  avif: (s, q) => s.avif({ quality: q, effort: 4 }),
};

async function score(refPng: string, candidate: Buffer, dir: string): Promise<number> {
  // ssimulacra2 needs decodable files; round-trip the candidate to PNG.
  const decoded = join(dir, "cand.png");
  await sharp(candidate).png().toFile(decoded);
  const { stdout } = await run("ssimulacra2", [refPng, decoded]);
  return Number(stdout.trim());
}

async function qualityFor(
  fmt: Fmt, source: sharp.Sharp, refPng: string, target: number, dir: string,
): Promise<{ q: number; bytes: number }> {
  let lo = 20, hi = 95, best = { q: hi, bytes: Number.POSITIVE_INFINITY };
  while (lo <= hi) {
    const q = Math.floor((lo + hi) / 2);
    const buf = await ENCODE[fmt](source.clone(), q).toBuffer();
    const s = await score(refPng, buf, dir);
    if (s >= target) { best = { q, bytes: buf.length }; hi = q - 1; }   // good enough: go lower
    else lo = q + 1;                                                     // too lossy: go higher
  }
  return best;
}

const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];

export async function calibrate(sampleDir: string): Promise<void> {
  const dir = await mkdtemp(join(tmpdir(), "qcal-"));
  const results: Record<Fmt, { q: number[]; bytes: number[] }> = {
    jpeg: { q: [], bytes: [] }, webp: { q: [], bytes: [] }, avif: { q: [], bytes: [] },
  };
  try {
    for (const name of (await readdir(sampleDir)).filter((f) => /\.(jpe?g|png|heic)$/i.test(f))) {
      const source = sharp(await readFile(join(sampleDir, name)))
        .rotate().resize({ width: WIDTH, withoutEnlargement: true }).toColourspace("srgb");
      const refPng = join(dir, "ref.png");
      await writeFile(refPng, await source.clone().png().toBuffer());

      // Reference: what does our accepted JPEG setting score on THIS image?
      const refJpeg = await ENCODE.jpeg(source.clone(), 80).toBuffer();
      const target = await score(refPng, refJpeg, dir);

      for (const fmt of ["jpeg", "webp", "avif"] as Fmt[]) {
        const r = await qualityFor(fmt, source, refPng, target, dir);
        results[fmt].q.push(r.q);
        results[fmt].bytes.push(r.bytes);
      }
    }
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
  for (const fmt of ["jpeg", "webp", "avif"] as Fmt[]) {
    const { q, bytes } = results[fmt];
    console.log(`${fmt.padEnd(4)} median q=${median(q)}  median bytes=${Math.round(median(bytes) / 1024)} KB  (n=${q.length})`);
  }
}

await calibrate(process.argv[2] ?? "./samples");
// jpeg median q=80  median bytes=96 KB  (n=312)
// webp median q=76  median bytes=68 KB  (n=312)
// avif median q=52  median bytes=49 KB  (n=312)

Line-by-line on the choices that matter

  • Per-image target from the reference JPEG. A noisy night photo and a flat product shot score very differently at JPEG 80. Using that image’s own reference score as the target compares formats fairly on each image, instead of forcing every image to an absolute score.
  • Binary search from 20 to 95. Scores increase monotonically with quality for all three encoders within that range, so seven encodes per format per image find the setting. Outside it, AVIF in particular gets non-monotonic at very low settings.
  • Median, not mean. A handful of pathological images — noise, fine text, gradients — need much higher settings and would drag the mean up for everyone. Use the median for the global setting and handle outliers separately (below).
  • effort: 4. AVIF and WebP effort trades encode time for size at the same quality. Calibrate at the effort you will run in production; a setting calibrated at effort 9 is wrong at effort 4.
  • .toColourspace("srgb") before scoring. Metrics assume sRGB. A Display P3 original scored without conversion penalises every format for a colour shift the browser would not show.

When the median is not enough

The median setting leaves a minority of images below target. Plotting per-image results shows who they are:

Distribution of required AVIF quality across 312 uploads A histogram of the AVIF quality needed to match the reference. Most images need between 44 and 58, peaking at 50 to 54. A small tail of about 6 percent needs 64 or more; these are screenshots and images with fine text. AVIF quality needed per image (n = 312) 40 48 52 60 70+ median 52 tail: text, screenshots Global q 52 serves 94% of uploads well; route the tail to a higher setting by content type.
A single number fits the bulk of photographs; screenshots and text-heavy images need their own profile.

Detecting the tail is cheap: images with a small palette, sharp edges and large flat regions are screenshots or graphics. Classify on upload — PNG input with fewer than 256 unique colours in a sample, or a high ratio of flat to textured blocks — and apply a “graphic” profile with higher AVIF quality and chromaSubsampling: "4:4:4", which preserves coloured text edges that 4:2:0 smears.

Configuration gotchas

AVIF colours shift on red text. Default AVIF chroma subsampling is 4:2:0. Red-on-white text and UI screenshots bleed. Use chromaSubsampling: "4:4:4" for the graphic profile; it costs 10–20% more bytes.

WebP output looks blotchy on gradients. libwebp’s lossy mode struggles with smooth gradients (skies, studio backdrops) at mid qualities. Raise quality for those or add smartSubsample: true, which improves chroma on sharp colour edges.

Calibration results change after npm update. A libvips or libaom update can shift the quality scale by several points. Pin sharp to an exact version in production and re-run calibration as part of any upgrade PR.

Error: spawn ssimulacra2 ENOENT. The metric binary is not on PATH in the environment running the script. Calibration is an offline job; run it on a developer machine or a CI image with the tool, never in the upload worker.

Building a sample that represents your uploads

The calibration is only as good as the sample. A folder of stock photography calibrates you for stock photography. Pull the sample from production instead: a few hundred recent uploads chosen at random, then topped up so every content type you care about has at least thirty images — product shots, selfies, food, screenshots, scanned documents, whatever your users actually send. Strip anything personal before the images leave production storage, or run the calibration inside the same environment.

Resize every sample image to the width you serve most often before calibrating, because compression behaves differently at different scales. Artefacts that are obvious in a 2000 px lightbox can be invisible at 400 px, and an encoder tuned on full-size originals will overspend on thumbnails. If two widths dominate your traffic, calibrate both and keep separate settings.

Record the calibration output — sample size, library versions, target, resulting settings and median bytes — in the repository next to the code that uses the numbers. When somebody asks in a year why AVIF is set to 52, the answer should be a file, not a memory.

The trade-off space, in one picture

Encode time versus bytes at matched quality At matched perceptual quality, JPEG encodes in about 15 milliseconds at 96 kilobytes, WebP in about 60 milliseconds at 68 kilobytes, and AVIF in about 400 milliseconds at 49 kilobytes for an 800 pixel image. 800 px photo, matched quality: bytes vs encode time 100 KB 0 JPEG · 96 KB · 15 ms WebP · 68 KB · 60 ms AVIF · 49 KB · 400 ms 0 ms 450 ms encode AVIF buys half the bytes for ~27× the encode time — cheap once per upload, expensive per request.
That asymmetry is why AVIF belongs at upload time or behind a warm cache, never on an uncached request path.

Verification

After pinning the settings, guard them with a regression check that runs in CI on a fixed subset of the sample:

import { strict as assert } from "node:assert";
import sharp from "sharp";
import { readFile } from "node:fs/promises";

const PINNED = { jpeg: 80, webp: 76, avif: 52 };
const MAX_BYTES = { jpeg: 130_000, webp: 95_000, avif: 70_000 };   // per 800 px fixture

const src = sharp(await readFile("fixtures/calibration-hero.jpg")).rotate().resize({ width: 800 });
const sizes = {
  jpeg: (await src.clone().jpeg({ quality: PINNED.jpeg, mozjpeg: true }).toBuffer()).length,
  webp: (await src.clone().webp({ quality: PINNED.webp, effort: 4 }).toBuffer()).length,
  avif: (await src.clone().avif({ quality: PINNED.avif, effort: 4 }).toBuffer()).length,
};
for (const f of ["jpeg", "webp", "avif"] as const) {
  assert.ok(sizes[f] <= MAX_BYTES[f], `${f} grew to ${sizes[f]} bytes — re-run calibration`);
}
console.log(sizes);

A sudden size jump after a dependency bump is the signal that the quality scale moved and calibration needs re-running.

Frequently Asked Questions

Can I just use quality: "auto" from an image CDN?

Yes, if you use one: CDN auto-quality modes run a similar per-image search internally. The calibration here matters when you encode yourself, and it is still useful to check that the CDN’s “auto” matches your reference, because each vendor chooses its own target.

Which metric should I use — SSIM, Butteraugli or SSIMULACRA 2?

Plain SSIM correlates poorly with how people judge compression artefacts. Butteraugli and SSIMULACRA 2 were designed for this and agree well with human ratings; SSIMULACRA 2 gives an easy 0–100 scale. Whichever you use, use the same one for every format.

Should thumbnails use the same settings?

Small images tolerate lower quality because artefacts are physically tiny, but encoder overhead makes the byte savings small too. Calibrate at your most-served width and reuse those settings for smaller widths unless measurement shows a clear gain.