Use near-lossless WebP when UI text must look exact, and lossy q90 when bytes matter more. On my test screenshot, nearLossless at level 40 was 17% smaller than lossless WebP, and no channel of any pixel moved by more than 4 out of 255. Lossy q90 was 81% smaller than lossless, but it moved some pixels by 77 and softened the text on a green button.
What are the three WebP modes?
WebP has two different codecs inside one format.
-
Lossy works in YUV with colour stored at reduced resolution, like a default JPEG. libvips calls its better option
smart_subsample, "high quality chroma subsampling". - Lossless is a separate mode that stores exact RGBA values.
-
Near-lossless is the lossless codec with a preprocessing step first. Google's cwebp docs describe
-near_losslessas a level from 0 (maximum preprocessing) to 100 (none, the default), and note that it turns on lossless mode.
In sharp, webp({ nearLossless: true }) switches the mode, and quality sets the level. The libvips webpsave docs suggest Q of 80, 60, 40 or 20 for increasing amounts of preprocessing. The sharp output docs give the defaults: quality 80, effort 4.
How do they compare on a real screenshot?
My test image is a Retina screenshot of Blinkimg's own pricing page, 2880 × 1800, halved to 1440 × 900. It has dark headings, a faint grid in the background, green badges and a green "Get started" button with white text. I encoded it seven ways and timed each one five times.
import sharp from 'sharp';
const src = 'screenshot-1x.png'; // 1440 x 900
const box = { left: 740, top: 624, width: 260, height: 64 }; // the green button
const ref = await sharp(src).removeAlpha().raw().toBuffer();
const refBox = await sharp(src).extract(box).removeAlpha().raw().toBuffer();
const stats = (a, b) => {
let sq = 0, max = 0;
for (let i = 0; i < a.length; i++) { const d = Math.abs(a[i] - b[i]); sq += d * d; if (d > max) max = d; }
return { psnr: sq === 0 ? Infinity : 10 * Math.log10(255 ** 2 / (sq / a.length)), max };
};
const runs = {
'png (level 9)': (s) => s.png({ compressionLevel: 9 }),
'webp lossless': (s) => s.webp({ lossless: true }),
'webp near-lossless 80': (s) => s.webp({ nearLossless: true, quality: 80 }),
'webp near-lossless 60': (s) => s.webp({ nearLossless: true, quality: 60 }),
'webp near-lossless 40': (s) => s.webp({ nearLossless: true, quality: 40 }),
'webp lossy q90': (s) => s.webp({ quality: 90 }),
'webp lossy q90 smart': (s) => s.webp({ quality: 90, smartSubsample: true }),
'webp lossy q80': (s) => s.webp({ quality: 80 }),
};
const input = await sharp(src).toBuffer();
for (const [name, fn] of Object.entries(runs)) {
const times = [];
let out;
for (let i = 0; i < 5; i++) {
const t = performance.now();
out = await fn(sharp(input)).toBuffer();
times.push(performance.now() - t);
}
times.sort((a, b) => a - b);
const all = stats(ref, await sharp(out).removeAlpha().raw().toBuffer());
const ban = stats(refBox, await sharp(out).extract(box).removeAlpha().raw().toBuffer());
console.log(name.padEnd(22), String(out.length).padStart(7), 'bytes', `${times[2].toFixed(0)} ms`.padStart(7),
' PSNR', all.psnr === Infinity ? 'exact' : all.psnr.toFixed(2),
' button', ban.psnr === Infinity ? 'exact' : ban.psnr.toFixed(2), ' max err', all.max);
}
png (level 9) 392945 bytes 240 ms PSNR exact button exact max err 0
webp lossless 323152 bytes 525 ms PSNR exact button exact max err 0
webp near-lossless 80 300790 bytes 744 ms PSNR 60.05 button 56.64 max err 1
webp near-lossless 60 280278 bytes 772 ms PSNR 55.50 button 51.86 max err 2
webp near-lossless 40 267204 bytes 756 ms PSNR 51.36 button 48.54 max err 4
webp lossy q90 61598 bytes 71 ms PSNR 43.52 button 34.63 max err 77
webp lossy q90 smart 63018 bytes 129 ms PSNR 43.72 button 34.17 max err 84
webp lossy q80 45898 bytes 65 ms PSNR 42.25 button 33.99 max err 68
| Mode | Bytes | vs PNG | Max error | Button PSNR | Median time |
|---|---|---|---|---|---|
| PNG, level 9 | 392,945 | 0 | exact | 240 ms | |
| WebP lossless | 323,152 | -18% | 0 | exact | 525 ms |
| Near-lossless 80 | 300,790 | -23% | 1 | 56.64 dB | 744 ms |
| Near-lossless 60 | 280,278 | -29% | 2 | 51.86 dB | 772 ms |
| Near-lossless 40 | 267,204 | -32% | 4 | 48.54 dB | 756 ms |
| Lossy q90 | 61,598 | -84% | 77 | 34.63 dB | 71 ms |
| Lossy q90, smartSubsample | 63,018 | -84% | 84 | 34.17 dB | 129 ms |
| Lossy q80 | 45,898 | -88% | 68 | 33.99 dB | 65 ms |
Why is there such a gap between the modes?
The lossless modes must keep every pixel of the faint background grid and every anti-aliased edge, so they stay large: lossless WebP saved only 18% over PNG here. Near-lossless 40 took another 17% off lossless, and never moved a channel by more than 4. On a flat UI that is invisible: an edge pixel shifts to its neighbour's shade.
Lossy WebP plays a different game. It cut 81% off lossless at q90, but some pixels moved by 77. Those are the text edges on the green button, where reduced-resolution colour mixes white into green. The button PSNR shows it: 48.54 dB for near-lossless 40, 34.63 dB for lossy q90. smartSubsample did not help on this image (34.17 dB, and 1,420 bytes larger).
Note:
qualitymeans two different things in sharp's WebP encoder. In lossy mode it is the usual quality. WithnearLossless: trueit is the preprocessing level, and lower means more change. Withlossless: truealone, libwebp treats it as compression effort, per Pillow's notes on the same libwebp setting. Do not reuse one "quality" variable across modes.
What does it cost in time?
Lossless and near-lossless are slow. At default effort, they took 525 to 772 ms for one 1440 × 900 frame on an M1 Pro. Lossy took 65 to 129 ms, several times faster. Timings move between runs, so read them as orders of magnitude, not exact figures.
That matters in a request path. If you convert screenshots on upload, put the encode in a background job, or cache the result by content hash so you pay once.
Which mode should you ship?
- Docs and tutorials where text must stay crisp: near-lossless 60 or 40. You save 13% to 17% over lossless and nobody can see the change.
- Pixel-exact needs (visual regression baselines, design handoff): plain lossless. It still beat PNG level 9 by 18%.
- Page weight first (a marketing page, a blog hero): lossy q90. It is about a fifth of the lossless size, but zoom into your coloured buttons before you ship it.
To try one file without code, the Blinkimg PNG to WebP converter takes a screenshot and returns a WebP, and What is WebP? covers browser support and the format's limits.
How I measured
- Machine: Apple M1 Pro, macOS, 2026-10-17.
- Node v24.22.0, sharp 0.35.4, libvips 8.18.6, libwebp 1.6.0.
- Source:
apps/web/assets/reference/screenshot.pngin the Blinkimg repo, a 2880 × 1800 Retina screenshot of Blinkimg's own pricing page, halved to 1440 × 900 with sharp's default resize. - Times are the median of 5 encodes from an in-memory PNG buffer, including decode. Bytes are identical across runs.
- PSNR is over RGB against the source; the button box is 260 × 64 at (740, 624).
What I did not test
Effort levels other than the default, alpha channels, and AVIF lossless. I did not check decode time in browsers.
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.


Top comments (0)