Why canvas.toBlob() Doesn't Compress PNGs, and What Actually Works in the Browser
I run a small browser-based file converter, and for a while my PNG compressor had a quiet bug: it did not compress anything. The code looked reasonable: canvas.toBlob((blob) => resolve(blob), 'image/png', 0.8);
I run a small browser-based file converter, and for a while my PNG compressor had a quiet bug: it did not compress anything.
The code looked reasonable:
canvas.toBlob((blob) => resolve(blob), 'image/png', 0.8);
Draw the image to a canvas, export it as PNG with quality 0.8, done. The output was usually the same size as the input, and sometimes bigger. Here is why, and what I replaced it with.
The quality argument only applies to lossy formats
The quality parameter of toBlob() and toDataURL() is only used for lossy formats such as image/jpeg and image/webp. For image/png the browser ignores it. You get whatever its built-in PNG encoder produces, and you have no control over filters or compression level.
So canvas.toBlob(cb, 'image/png', 0.8) is just a re-encode. If the source PNG was already written by a decent encoder, you gain nothing.
Where the savings in a PNG actually are
A PNG is a pixel grid, a per-row filter, and DEFLATE on top. There are two ways to make the file smaller:
- Lossless re-encoding. Try better filters and a stronger DEFLATE setting. The pixels stay identical, but savings are usually small, often single-digit to low double-digit percent.
- Palette quantization. Reduce the image from 32-bit RGBA to an indexed palette of up to 256 colors, keeping the alpha channel. This is lossy, but for screenshots, logos, icons and UI graphics it is usually invisible, and the files get much smaller.
Tools like TinyPNG get most of their savings from option 2.
Doing it in the browser with UPNG.js and pako
UPNG.js is a small PNG encoder/decoder from the Photopea author. It uses pako for DEFLATE. Its encode() takes raw RGBA data and a color count: 0 means lossless, 256 means quantize to a 256-color palette.
<script src="/assets/js/pako.min.js"></script>
<script src="/assets/js/UPNG.js"></script>
function compressPng(file, mode) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data.buffer;
const candidates = [UPNG.encode([rgba.slice(0)], canvas.width, canvas.height, 0)];
if (mode === 'smart') {
candidates.push(UPNG.encode([rgba.slice(0)], canvas.width, canvas.height, 256));
}
const best = candidates.reduce((a, b) => (b.byteLength < a.byteLength ? b : a));
URL.revokeObjectURL(url);
// never hand back a file bigger than the original
resolve(best.byteLength < file.size ? new Blob([best], { type: 'image/png' }) : file);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Could not read PNG')); };
img.src = url;
});
}
A few details that matter:
-
getImageData()returns non-premultiplied RGBA, which is what UPNG expects, so transparency survives. -
UPNG.encode()may use the buffer you pass it, so passrgba.slice(0)when you encode twice. - Comparing the candidates and falling back to the original file means users never get a "compressed" file that is larger.
What the numbers look like
I tested a 1000x700 synthetic image, a noisy gradient with a transparent strip at the top. This is a hard case, not a typical one:
| Mode | Size |
|---|---|
| Original | 1.50 MB |
Lossless (cnum = 0) |
1.43 MB (−5%) |
| 256-color palette | 231 KB (−85%) |
The lossless number is the honest result of re-encoding: a few percent. The big win comes from quantization. Your own images will vary, so treat these as an illustration and not a benchmark.
When not to quantize
Palette reduction can cause banding in smooth gradients and photos. For those, you have better options:
- Keep lossless mode available, so users who need pixel-exact output can choose it.
- Convert photos to JPEG or WebP.
canvas.toBlob(cb, 'image/webp', 0.8)does honor the quality value, and WebP supports transparency. - Reduce dimensions if the image is larger than it will ever be displayed.
I ended up with two modes in the UI: "Smart" (tries both and keeps the smaller) and "Lossless".
Privacy comes for free
Because everything above runs in the browser, the image never leaves the device. No upload, no server to trust, nothing to delete afterwards. That was the reason I built the tool in the first place, and it is a real advantage for screenshots with private data or unreleased designs.
If you want to see the result, the compressor is here: oneweeb.com/compress-png.html. It is free, with no sign-up.
Takeaways
-
qualityis ignored forimage/pngintoBlob(). - Real PNG savings come from palette quantization, not from re-encoding.
- Offer a lossless mode, compare outputs, and never return a bigger file than the input.
- Client-side processing keeps user files private.
Have you run into other canvas export surprises? I would like to hear about them in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.