Every image tool ships the same control: Quality: 0.75. It looks like a compression dial. It is not one. It is an argument you hand to canvas.toBlob(), and the encoder is allowed to do whatever it wants with it.
So I stopped trusting the label and measured the bytes.
The test
One file: a real 1200×630 PNG — the social card from a site I run — 44,915 bytes. Loaded into a canvas in HeadlessChrome 149, re-encoded with toBlob(type, quality), then read Blob.size. Same image, same tab, same machine. Only the format and the number changed.
| quality | JPEG | WebP |
|---|---|---|
| 1.00 | 218,307 | — |
| 0.95 | 109,443 | 41,074 |
| 0.92 | 90,251 | 35,916 |
| 0.85 | 72,233 | 27,792 |
| 0.75 | 58,265 | 22,212 |
| 0.60 | 46,603 | 19,178 |
| 0.40 | 36,427 | 16,440 |
| 0.10 | 16,194 | 11,370 |
| (source PNG) | 44,915 | 44,915 |
Three things fell out of that table, and none of them match the label.
1. JPEG doesn't beat the source until about 0.57
At the default 0.75, the "compressed" JPEG is 58,265 bytes — 30% larger than the PNG it started from. At 0.60 it's still slightly bigger (46,603). The encoder only starts paying you back somewhere between 0.60 and 0.40.
At quality = 1.0 the output is 4.9× the original. That's not a bug. Quality 1.0 means "don't scale the quantization tables down", and an unscaled JPEG table spends bits on details a flat graphic doesn't have.
The failure isn't random. It hits the exact files people feed into tools like this:
- screenshots
- UI art, icons, logos
- flat colour with hard edges
- anything already exported by a tool that knew what it was doing
A camera photo is a different story — it has real tonal noise everywhere, so JPEG's DCT fits it well and 0.75 genuinely shrinks it. A screenshot doesn't. Run the same slider over both and you get opposite results, which is why "compression" tools that report a percentage sometimes report +30% and sometimes -70%.
2. WebP wins at 0.95 and never loses
Same canvas, same draw call, image/webp:
- 0.95 → 41,074 bytes, already 8.6% smaller than the source PNG
- 0.75 → 22,212 bytes, half
- 0.40 → 16,440, and visually still fine for a graphic
WebP beats JPEG at every quality level in the table, including 0.10. Part of that is better entropy coding. Part is that lossy WebP has a real intra-block prediction mode that handles flat regions and hard edges instead of ringing around them.
If your pipeline ends in a JPEG because "JPEG is for photos and photos are what this tool handles", you're paying a format tax on every file. One line changes it:
// canvas.toBlob with WebP is supported everywhere canvas is
canvas.toBlob(blob => save(blob), 'image/webp', 0.8);
3. image/png ignores the third argument. Completely.
Same canvas, toBlob('image/png', q):
- q = 1.0 → 71,663 bytes
- q = 0.5 → 71,663 bytes
- q = 0.1 → 71,663 bytes
Byte-for-byte identical. PNG is lossless, so there is nothing for a quality number to control — the spec says the argument is ignored, and Chromium does exactly that. Two consequences worth knowing:
- A "PNG compressor" built on
canvas.toBlob('image/png')has a decorative slider. Moving it changes nothing but the user's confidence. - The canvas re-encode here is 71,663 bytes vs 44,915 for the original file — 60% bigger. Your browser's PNG encoder is fast, not good. Real PNG compression means
pngquant/oxipng-class work: palette reduction, filter heuristics, multiple passes. A canvas round-trip can't do any of it.
The trap: "compress it again"
Re-encoding an already-compressed JPEG at the same quality does something worse than nothing. I took the JPEG produced at 0.92 and fed it back through the canvas four times:
| generation | bytes |
|---|---|
| 1 | 89,634 |
| 2 | 89,526 |
| 3 | 89,390 |
| 4 | 89,331 |
The size barely moves — -0.3% over four generations — while every pass throws away detail and re-quantizes what survived. You don't get a smaller file. You get a slightly smaller file that looks worse, and the loss compounds. If a tool tells you it "compressed" a JPEG by 1%, it probably just spent some quality for nothing.
What the slider is actually doing
quality is a hint, not a coefficient. In Chromium it lands in libjpeg-turbo, where it scales the quantization tables — roughly, lower quality = coarser tables = fewer non-zero coefficients to encode. It says nothing about:
- chroma subsampling (4:2:0 vs 4:4:4 — often bigger than the quality number)
- whether the input was already quantized at a similar level
- how much of the image is flat vs textured
- metadata (EXIF, ICC) — canvas drops all of it, which is another silent change
That's why two files with identical dimensions and identical quality can come out 5× apart in size.
The rules I actually use now
- Read the byte readout, not the slider. Any tool worth using shows before/after size. If it doesn't, you're guessing.
- Photos → JPEG 0.75–0.85 or WebP 0.8. Both fine; WebP usually 20–30% smaller.
- Screenshots and flat graphics → WebP, or keep the PNG. JPEG will fight you and lose.
- Never re-encode a file you already compressed. Go back to the original or accept generation loss.
- If the format has no quality knob (PNG), the knob is theatre. Check whether the output size actually changes when you move it — that's a five-second test and it catches a surprising number of tools.
Measure it yourself
The whole experiment is about fifteen lines. Run it in any page's console against any image:
const c = document.createElement('canvas');
const img = await createImageBitmap(await (await fetch('/your-image.png')).blob());
c.width = img.width; c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
const bytes = (type, q) =>
new Promise(res => c.toBlob(b => res(b.size), type, q));
for (const q of [1, 0.92, 0.75, 0.4]) {
console.log('jpeg', q, await bytes('image/jpeg', q));
console.log('webp', q, await bytes('image/webp', q));
}
If the JPEG number comes out larger than the file you started with, the tool wasn't compressing. It was re-encoding.
The two browser-side tools I built while chasing this: JPG compressor (quality slider wired straight to toBlob) and JPG to WebP converter — both report the real before/after bytes, so you can watch the table above happen on your own file. No upload, no account, nothing leaves the tab.
Top comments (0)