A fixed JPEG quality gives a different visual result on every image. Pick the quality per image instead: encode, measure SSIM against the original, and binary-search for the lowest quality that reaches your target. In my run, an SSIM target of 0.97 chose quality 21 for a screenshot, 52 for one photo and 74 for another, and the screenshot came out 37.9 KB instead of 90.6 KB at quality 75.
Why does one quality number not fit every image?
The quality setting scales the quantization tables. It does not promise a level of fidelity. A noisy camera photo loses more detail at quality 75 than a flat screenshot does. Here is what quality 75 scored on three files at 1920 px wide:
| File | Bytes at q75 | SSIM at q75 |
|---|---|---|
| photo.jpg (iguana, 1920 × 1280) | 253.3 KB | 0.9828 |
| screenshot.png (1920 × 1200) | 90.6 KB | 0.9918 |
| camera.jpg (boat at sunset, 1920 × 1076) | 125.8 KB | 0.9716 |
So the same setting overshoots on the screenshot and undershoots on the camera photo. An SSIM target fixes the result and lets the bytes vary.
What is SSIM, in one paragraph?
SSIM (structural similarity) compares two images window by window on brightness, contrast and structure, and averages the result. 1.0 means identical. It was introduced by Wang, Bovik, Sheikh and Simoncelli in 2004 (the authors' SSIM page). It tracks what people notice better than PSNR does, because it looks at local structure and not raw pixel error.
The search, in Node with sharp
The code below computes SSIM on luma over 8 × 8 windows at a stride of 4, then binary-searches quality from 1 to 100. It assumes SSIM rises with quality, which held on every file I tried.
import sharp from 'sharp'; // Luma (BT.601) of an image as a Float64Array. async function luma(input) { const { data, info } = await sharp(input).removeAlpha().raw() .toBuffer({ resolveWithObject: true }); const y = new Float64Array(info.width * info.height); for (let i = 0, p = 0; i < y.length; i++, p += info.channels) y[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2]; return { y, w: info.width, h: info.height }; } // Mean SSIM over 8 x 8 windows, stride 4. function ssim(a, b, w, h) { const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2; let total = 0, n = 0; for (let y0 = 0; y0 + 8 <= h; y0 += 4) { for (let x0 = 0; x0 + 8 <= w; x0 += 4) { let sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0; for (let y = y0; y < y0 + 8; y++) { for (let x = x0; x < x0 + 8; x++) { const i = y * w + x, va = a[i], vb = b[i]; sa += va; sb += vb; saa += va * va; sbb += vb * vb; sab += va * vb; } } const ma = sa / 64, mb = sb / 64; const va = saa / 64 - ma * ma, vb = sbb / 64 - mb * mb, cov = sab / 64 - ma * mb; total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2)); n++; } } return total / n; } // Lowest JPEG quality whose SSIM reaches `target`: binary search over 1 to 100. export async function qualityFor(input, target) { const ref = await luma(input); const tried = new Map(); async function score(q) { if (!tried.has(q)) { const buf = await sharp(input).jpeg({ quality: q, mozjpeg: true }).toBuffer(); const out = await luma(buf); tried.set(q, { q, bytes: buf.length, ssim: ssim(ref.y, out.y, ref.w, ref.h) }); } return tried.get(q); } let lo = 1, hi = 100; while (lo < hi) { const mid = (lo + hi) >> 1; if ((await score(mid)).ssim >= target) hi = mid; else lo = mid + 1; } return { ...(await score(lo)), encodes: tried.size }; } The driver decodes each file once to PNG at 1920 px wide, so every candidate starts from the same pixels, then tries three targets.
What did it pick?
photo.jpg 1920x1280 fixed q75: 253349 B ssim 0.9828 target 0.95: q31 113979 B ssim 0.9509 7 encodes 1014 ms target 0.97: q52 166433 B ssim 0.9701 7 encodes 1259 ms target 0.99: q88 387217 B ssim 0.9902 6 encodes 1407 ms screenshot.png 1920x1200 fixed q75: 90566 B ssim 0.9918 target 0.95: q10 23612 B ssim 0.9507 6 encodes 548 ms target 0.97: q21 37910 B ssim 0.9716 7 encodes 666 ms target 0.99: q69 81470 B ssim 0.9900 6 encodes 708 ms camera.jpg 1920x1076 fixed q75: 125834 B ssim 0.9716 target 0.95: q45 67354 B ssim 0.9502 7 encodes 837 ms target 0.97: q74 125403 B ssim 0.9715 7 encodes 978 ms target 0.99: q95 364076 B ssim 0.9905 7 encodes 1532 ms | File | Target 0.95 | Target 0.97 | Target 0.99 |
|---|---|---|---|
| photo.jpg | q31, 114.0 KB | q52, 166.4 KB | q88, 387.2 KB |
| screenshot.png | q10, 23.6 KB | q21, 37.9 KB | q69, 81.5 KB |
| camera.jpg | q45, 67.4 KB | q74, 125.4 KB | q95, 364.1 KB |
At target 0.97 the quality ranged from 21 to 74. The camera photo landed almost exactly where q75 already was, so the search saved it nothing. The other two files dropped by 34% and 58%. Each search took 6 or 7 encodes, as a binary search over 100 values should.
Where does an SSIM target go wrong?
Note: this SSIM reads luma only. JPEG's chroma subsampling smears coloured text and thin coloured lines, and a luma score never sees it. The screenshot at q10 scored 0.9507, yet its green label text turned grey and blocky. For screenshots, keep a floor on quality or compare chroma too.
Two more limits. Mean SSIM averages the whole frame, so a damaged face in a large sky can still score well. And the search costs 6 to 7 encodes per image; my timings above ran on a busy machine, so treat the milliseconds as rough. Run it in a background worker, not in a request.
Which target should you pick?
Start at 0.97 for photos, look at the results at 100% zoom, then move by 0.01. Above 0.99 the bytes climb fast: the camera photo needed 364.1 KB for 0.99, nearly 3 times its 0.97 size. If a hard byte budget matters more than a fidelity target, search on size instead; that is what the Max file size field of the JPEG compressor does, and targetBytes in its API.
How I measured
- Machine: Apple M1 Pro (8 cores), macOS, 2026-10-17. Other jobs ran on the machine at the same time, so the millisecond figures are rough; the bytes and SSIM are deterministic.
- Node v24.22.0, sharp 0.35.4, libvips 8.18.6, mozjpeg as bundled with sharp.
- Files:
photo.jpg(1920 × 1280) andscreenshot.png(2880 × 1800, resized to 1920 × 1200) from the site's reference set;camera.jpgis the site's 4964 × 2782 camera photo, resized to 1920 × 1076. - SSIM: my implementation above, luma only, 8 × 8 windows, stride 4, mean over all windows.
What I did not test
Gaussian-window SSIM, MS-SSIM, SSIMULACRA 2 or Butteraugli. WebP and AVIF, where the same search works but the quality-to-SSIM curve differs.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

