Compress Images in the Browser with Canvas — Working Code
Every browser ships an image encoder, and the Canvas API's toBlob() is the door to it. This is the compression that runs in production on this site, with the code: what the quality number actually maps to, which encoder each browser hides behind the same call, how a quality ladder walks down to a byte target — and the naive version that hands you a file bigger than the one you started with, without saying so.
The Canvas pipeline
Browser-based compression follows a simple pipeline: decode the source image into raw pixel data, draw those pixels onto a Canvas, then re-encode the canvas contents into a compressed format.
The decode step happens when you set an Image element's src. The browser reads the file (JPEG, PNG, WebP, whatever) and converts it into an uncompressed bitmap in memory. The encode step happens when you call toBlob() or toDataURL() — the browser runs its built-in encoder to produce a new compressed file.
toBlob() — the main compression method
The toBlob() method is where compression actually happens. It takes three arguments:
canvas.toBlob( callback, // function that receives the Blob mimeType, // "image/jpeg", "image/webp", or "image/png" quality // 0 to 1 (only for JPEG and WebP) );
The quality parameter is the key control. At 1.0, you get maximum quality with minimal compression. At 0.1, you get aggressive compression with visible artifacts. The sweet spot for most photos is 0.7 to 0.85.
Quality parameter deep dive
The quality value maps to the internal quantization tables used by the JPEG encoder. Here is how different values affect a typical 3 MB photo:
The relationship between quality and file size is not linear. Dropping from 0.95 to 0.85 saves a lot of space with almost no visible difference. Dropping from 0.40 to 0.20 saves much less space but introduces very visible degradation.
The naive version makes files bigger — and never tells you
The first compressor everyone writes is three lines: draw the image on a canvas, call toBlob() at quality 0.8, download the result. It works on a 12-megapixel camera file. It fails silently on the files people actually upload. A JPEG that was already saved at quality 0.6 by a phone or a chat app is re-encoded at 0.8 and comes back larger — the encoder is now spending bytes on faithfully reproducing the previous encoder's artefacts. A small PNG logo drawn to a canvas and exported as JPEG grows too, and loses its transparency on the way. toBlob() has no idea what the source weighed, so nothing in the API warns you.
The fix is the same in every production compressor, including this site's: compare the output against the input and refuse to ship a loss. A ladder that walks down through qualities until the output fits under a byte target, and keeps the original untouched when no rung wins, looks like this:
async function compressUnder(file, targetBytes) {
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
let best = null;
for (const quality of [0.9, 0.8, 0.7, 0.6, 0.5, 0.4]) {
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
if (blob.size < file.size && (!best || blob.size < best.size)) best = blob;
if (best && best.size <= targetBytes) break; // first rung under target wins
}
// No rung beat the input: the honest answer is the input itself.
return best ?? file;
}Two details carry the weight. The comparison is against file.size, the bytes the user gave you, not against the previous rung — so a source that is already smaller than anything the ladder can produce comes back unchanged instead of "compressed" into something heavier. And the ladder stops at the first quality that clears the target rather than always running to the bottom, because every rung below that one throws away detail for no reason. MiniPx's own pipeline adds a third rule on top: when the output is not smaller by a margin worth having, it hands back the original file and says so, because a 0.3 % saving is not a result anyone asked for.
Format differences in toBlob()
JPEG (image/jpeg)
JPEG is lossy and works best for photographs. The quality parameter works as expected. One catch: JPEG does not support transparency. If your canvas has transparent areas, they will be filled with black in the JPEG output.
WebP (image/webp)
WebP supports both lossy and lossless compression. With a quality parameter, it behaves like JPEG but produces 25-35% smaller files. WebP also supports transparency, making it a good all-around format.
PNG (image/png)
PNG is always lossless in the Canvas API. The quality parameter is ignored. PNG files from the Canvas API tend to be larger than optimized PNGs because the browser uses basic deflate compression without advanced techniques like color quantization.
Browser differences matter
Different browsers use different encoder implementations. Chrome uses libjpeg-turbo for JPEG and libwebp for WebP. Firefox has its own implementations. Safari uses Apple's native image frameworks.
In my tests, the same image at quality 0.8 produced a 487 KB file in Chrome, 512 KB in Firefox, and 495 KB in Safari. The visual quality was comparable across all three, but Chrome's output was consistently a few percent smaller.
OffscreenCanvas for background processing
Regular canvas operations block the main thread. For large images or batch processing, OffscreenCanvas lets you run compression in a Web Worker.
// In your Web Worker:
self.onmessage = async (e) => {
const { imageBitmap, quality } = e.data;
const canvas = new OffscreenCanvas(
imageBitmap.width,
imageBitmap.height
);
const ctx = canvas.getContext('2d');
ctx.drawImage(imageBitmap, 0, 0);
const blob = await canvas.convertToBlob({
type: 'image/jpeg',
quality: quality
});
self.postMessage({ blob });
};Note the API difference: OffscreenCanvas uses convertToBlob() (which returns a Promise) instead of toBlob() (which uses a callback). MiniPx uses OffscreenCanvas in its AI background-removal engine; the compressor itself still runs toBlob() on the main thread.
Limitations and workarounds
Canvas size limits. Most browsers cap canvas dimensions at 16,384 x 16,384 pixels. Some mobile browsers have lower limits. If an image exceeds this, you will need to downscale it before drawing to the canvas.
No EXIF preservation. The Canvas API strips all metadata. This is good for privacy but means you lose orientation data. Always apply EXIF orientation before drawing to the canvas, or your rotated photos will appear sideways.
Color space issues. Canvas works in sRGB by default. If your source image uses a different color space (like Adobe RGB or Display P3), colors may shift during compression. For most web images, this is not a problem.
Frequently asked questions
Related tools
More from the blog
Compress, convert, and resize images in your browser. Nothing gets uploaded.
Open MiniPx →