A while ago I wrote about why I built 50+ browser-based tools with zero backend. This time I want to go deeper into one specific tool: image compression that runs entirely in the browser. No upload, no server, no API — just the Canvas API doing all the work on the user's device.
Here's how it works, plus a couple of gotchas that cost me real debugging time.
The core idea
Image compression in the browser is surprisingly simple in principle:
- Read the user's file with the File API
- Draw it onto a
<canvas> - Re-export the canvas as a Blob at a lower quality
- Trigger a download
That's it. The user's photo never leaves their machine.
The code
async function compressImage(file, quality = 0.7) {
// 1. Load the file into an Image element
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
// 2. Draw it onto a canvas
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 3. Re-export as a compressed Blob
const blob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/jpeg', quality)
);
URL.revokeObjectURL(img.src);
return blob;
}
Then downloading is just:
function downloadBlob(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
Gotcha #1: transparent PNGs turn black
This one bit me in production. If the input is a PNG with transparency and you export it as JPEG, the transparent areas become solid black — JPEG has no alpha channel.
Two ways to handle it:
Option A — fill a white background first:
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
Option B — detect transparency and export WebP instead (WebP supports alpha and compresses well):
const outputType = hasTransparency ? 'image/webp' : 'image/jpeg';
canvas.toBlob(resolve, outputType, quality);
I went with Option B — users who upload a transparent logo expect to get a transparent result back.
Gotcha #2: compression can make files BIGGER
If the input is already heavily optimized (or tiny), re-encoding it can produce a larger file. Always compare sizes and never return a worse result:
if (blob.size >= file.size) {
return file; // keep the original
}
Sounds obvious, but a lot of online compressors skip this check and happily hand you a "compressed" file that's bigger than what you started with.
Gotcha #3: huge images can crash the tab
A 50-megapixel photo drawn onto a canvas can eat enough memory to kill the tab on mobile — silently, with no error. Add a size guard before decoding and show a friendly message instead of letting the browser die.
Why bother doing this client-side?
- Privacy: the user's photos never touch a server. Nothing to store, nothing to leak.
- Cost: zero bandwidth and zero server bills, no matter how many people use it.
- Speed: no upload/download round-trip — compression starts instantly.
The limits are real too: no server means no ML-based smart compression, and very large batches are constrained by device memory. For everyday "shrink this photo before emailing it" use cases, though, the browser is more than enough.
Try it live
I use exactly this approach (plus resize-before-compress for extra savings) in the Image Compressor on SwiftTooly — free, no sign-up, and your files stay on your device.
Questions about the implementation? Happy to share more details in the comments — the PDF compression story is even messier, and I might write that one up next.
Top comments (0)