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
sharpor libvips and want to know whether the numbers still mean what they used to.
Prerequisites
- Node 20+ with
sharp0.33+ — the same version you run in production. - The
ssimulacra2command-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. - 200–500 real uploads, stratified by type, resized to your most-served width (the calibration is width-sensitive).
- 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
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:
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
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.