DEV Community

sharefun2023
sharefun2023

Posted on

Your PNG quality slider does nothing — I measured canvas.toBlob() for PNG, JPEG and WebP

Every image tool on the web has a "quality" slider. Almost nobody tells you which formats actually obey it.

I build client-side image tools, so the slider isn't cosmetic for me — it's the whole feature. I sat down with a headless Chrome and measured what canvas.toBlob() really does with that argument, format by format. Here are the raw numbers, and the two places where the mental model everyone has is simply wrong.

The setup

One 1200×800 canvas, deliberately "photo-like" so a lossy encoder has something to chew on:

  • a three-stop linear gradient across the whole frame,
  • 6,000 semi-transparent noise rectangles (4×4 px),
  • 72 px monospace text on top.

Then I encoded the same pixels over and over with different quality values. Chrome 149 headless, Linux, nothing exotic. Everything below is canvas.toBlob(callback, type, quality) on identical source pixels — no file I/O, no libraries.

Format / quality Output bytes vs. the best case
PNG, q = 0.1 1,345,909 —
PNG, q = 1.0 1,345,909 0.0% change
JPEG, q = 1.0 962,887 baseline
JPEG, q = 0.8 96,345 −90.0%
JPEG, q = 0.6 57,971 −94.0%
JPEG, q = 0.1 12,124 −98.7%
WebP, q = 1.0 477,822 baseline
WebP, q = 0.8 68,194 −85.7%
WebP, q = 0.3 26,504 −94.5%

Finding 1: PNG ignores the quality argument completely

Not "a little", not "with a small effect". Byte-identical. 1,345,909 bytes at q = 0.1 and at q = 1.0, and a second 200×200 canvas gave 17,279 bytes at both settings too.

The reason is boring once you see it: PNG is a lossless format. The ninth argument of the WebP/JPEG encoder is a quantisation target; PNG has no such concept. Chrome's PNG encoder takes the quality value and throws it away.

Which means every product page claiming "PNG compression: set quality to 80% and save 50%" is describing something no browser can do. If you see a PNG quality slider, it is decoration.

What actually makes a PNG smaller:

  • fewer pixels — downscaling is the only lever that always works,
  • fewer colours — converting to a palette and quantising (that needs a real PNG encoder such as UPNG/ImageQuant, not toBlob),
  • a different format — for photographic content WebP or AVIF wins outright, and both keep transparency.

Finding 2: WebP beat JPEG at every quality setting I tried

Look at the table again. Same pixels, same machine, same call:

  • q = 1.0 → JPEG 962,887 vs WebP 477,822 (WebP is half the size),
  • q = 0.8 → JPEG 96,345 vs WebP 68,194.

Chrome's image/jpeg encoder at quality 1.0 is still a lossy encode — it just targets a very high fidelity and stops caring about size. WebP's q = 1.0 is also lossy, but its ceiling is far cheaper.

The practical takeaway: if the browser can encode WebP, "I'll use JPEG because WebP might be bigger" is not a good default any more. Check it per image if you care, but on photographic pixels the ordering held for me across the whole range.

Two caveats worth writing down, because someone will hit them:

  1. The curve is source-dependent. My synthetic gradient plus noise is a compression-friendly worst case for JPEG (smooth areas plus fine detail). Flat vector-ish artwork compresses very differently and the percentages shrink a lot. Never quote a compression percentage without saying what image it came from.
  2. q = 1.0 JPEG is still lossy. There is no "lossless JPEG" in canvas. If you need bit-accuracy, the format is PNG or WebP (lossless) — not a higher quality number.

The part that isn't about quality at all

toBlob() does not copy your file. It encodes the RGBA pixels currently on the canvas. Three consequences people trip over:

Metadata is gone. The canvas holds pixels, not a container. EXIF, GPS, XMP and the ICC profile do not survive drawImage → toBlob. Sometimes that's the feature (stripping location data before publishing); sometimes it silently breaks a colour-managed workflow. Either way it is a side effect, not a setting.

Alpha survives for PNG and WebP, and cannot survive JPEG. JPEG has no alpha channel. Transparent regions come out black unless you paint something first:

const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
Enter fullscreen mode Exit fullscreen mode

If you're exporting SVG to JPEG and your logo's transparent background turned into a black box, this line is the fix.

Animation is flattened. Canvas is a single raster. Draw an animated GIF, APNG or animated WebP into it and you get one frame — whatever frame the image happens to be showing. There is no frame count in toBlob.

Where this bites hardest: SVG → PNG

This is the exact path an SVG-to-PNG converter takes:

  1. serialise the SVG and load it into an <img>,
  2. drawImage() it onto a canvas at the pixel size you want,
  3. canvas.toBlob(cb, 'image/png') and hand the result to the user.

Step 3 is the PNG row of the table above. The quality argument is quietly discarded.

So when you're looking for a "higher quality" PNG export of a vector file, the quality field is the wrong control — it does nothing, in any browser. The only lever that changes the output is how many pixels you rasterise to. A 24×24 icon exported at 512×512 is 512×512 real pixels; the SVG's own width/height attributes don't cap it, and a file with only a viewBox has no intrinsic size at all (browsers fall back to 300×150 when they must).

I keep a browser-only converter at imgloft.com/svg-to-png where the only input that matters is the output size, precisely because that's the only one that does anything. If you want the long version of the SVG rasterisation pitfalls — external resources silently skipped, webfonts that never load, currentColor resolving to black — they're written up in the SVG to PNG guide.

Reproduce it in twenty lines

Paste this in any browser console. It rebuilds the test canvas and prints the same table:

const c = document.createElement('canvas');
c.width = 1200; c.height = 800;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 1200, 800);
g.addColorStop(0, '#1b3a6b');
g.addColorStop(0.5, '#c94f2a');
g.addColorStop(1, '#f2e6c9');
ctx.fillStyle = g; ctx.fillRect(0, 0, 1200, 800);
for (let i = 0; i < 6000; i++) {
  ctx.fillStyle = `rgba(${Math.random()*255|0},${Math.random()*255|0},${Math.random()*255|0},0.35)`;
  ctx.fillRect(Math.random()*1190, Math.random()*790, 4, 4);
}
ctx.fillStyle = '#111'; ctx.font = '72px monospace'; ctx.fillText('MEASURED', 60, 200);

const enc = (type, q) => new Promise(r => c.toBlob(b => r(b.size), type, q));
(async () => {
  console.log('png  0.1', await enc('image/png',  0.1));
  console.log('png  1.0', await enc('image/png',  1.0));
  console.log('jpeg 0.8', await enc('image/jpeg', 0.8));
  console.log('webp 0.8', await enc('image/webp', 0.8));
})();
Enter fullscreen mode Exit fullscreen mode

The two PNG numbers will match. They always match.

One gotcha if you wire this to a button

My first attempt at measuring this was wrong, and the mistake is easy to make. The convert button in a typical pipeline is disabled while the async encode is in flight:

btn.disabled = true;
canvas.toBlob(async (blob) => { /* ... */ btn.disabled = false; }, type, quality);
Enter fullscreen mode Exit fullscreen mode

A disabled button does not dispatch click. So a script that clicks, changes the quality, and clicks again gets the first result twice — and you conclude the quality setting is ignored. I nearly published "WebP ignores quality too" on that basis. Reload the page between measurements, or poll until btn.disabled === false before clicking again.

Same trap catches users, not just testers: double-clicking a convert button mid-encode does nothing at all, which is usually the desired behaviour and occasionally a silent dropped job.

The short version

  • PNG: quality is ignored — byte-identical output. Resize, quantise, or switch format.
  • JPEG: quality works, hard. Watch out for the transparent-to-black default and for q = 1.0 still being lossy.
  • WebP: quality works, and it beat JPEG at every setting on my test pixels, alpha included.
  • Everything: toBlob re-encodes pixels, so metadata is dropped, animation is flattened, and the only "more quality" lever for vector sources is more pixels.

If you build tools like these, measure the numbers yourself before you write them on a landing page. A quality slider that does nothing is a much worse look than no slider at all.

Top comments (0)