DEV Community

Cover image for WebP lossless vs near-lossless vs lossy for UI screenshots
Sahadat Husain
Sahadat Husain

Posted on

WebP lossless vs near-lossless vs lossy for UI screenshots

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_lossless as 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);
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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

Bar chart of bytes per mode: PNG 392,945, lossless 323,152, near-lossless 80, 60 and 40 at 300,790, 280,278 and 267,204, lossy q90 61,598 and q80 45,898

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).

Three 4x zooms of a green Get started button: lossless, near-lossless 40 that looks the same, and lossy q90 with softer, tinted letter edges

Note: quality means two different things in sharp's WebP encoder. In lossy mode it is the usual quality. With nearLossless: true it is the preprocessing level, and lower means more change. With lossless: true alone, 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.png in 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)