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 (4)
Useful for bandwidth. One catch if any of these images are headed to an AI model: token cost comes from dimensions, not file size. A 300KB compressed copy and the 1.2MB original at the same pixel size cost the model exactly the same. And if you downscale to shrink it, a tall image loses its text, the width collapses with the height. Compression is a storage win, not a token win.
Fair point, and worth spelling out for anyone feeding images into an LLM.
Token cost comes from pixel dimensions, not bytes, so a 300KB copy and the
1.2MB original at the same resolution cost the model exactly the same.
Compression saves bandwidth and storage but does nothing for tokens.
The downscaling part is where people get stuck. Aspect ratio is locked, so
shrinking a tall screenshot to cut its width pulls the height down with it,
and the text stops being readable before you've saved much. Cropping to the
region that matters works better, or splitting a long screenshot into
sections.
Might be worth its own post. It's a different problem from the one I wrote
about here.
Splitting into sections is exactly what we landed on too. Capture the full page, cut it at each viewport boundary, annotate each section separately, model reads full resolution every time instead of one shrunk mega-image. Only gotcha: if a fact needs to show up in two sections, repeat it near the top and again near the bottom so each section stands alone. Built a connector around this if useful: slimsnap.ai/connector
Good writeup, and gotcha #1 is the one everybody hits eventually. Three more from the same territory, all of which cost me time:
HEIC breaks the whole flow. Photos straight off an iPhone are HEIC, and no browser decodes it natively — img.decode() just rejects. Since the point of this approach is that the file never leaves the device, you can't punt it to a server either, so you end up shipping a decoder to the client. It works, but it's the one operation where you really feel the weight of doing everything locally.
file.type is sometimes an empty string. On iOS in particular, files arriving through the share sheet or certain pickers come with no MIME type at all. If you validate with something like file.type.startsWith('image/'), those get silently rejected and the user has no idea why. Falling back to the extension when the type is empty fixed it for me.
Your downloadBlob() is fine on desktop and dead on phones. An anchor with the download attribute does nothing in an Android WebView (no download manager unless the host app wires one up) and iOS Safari refuses it on blob: and data: URLs from a synthetic click. No error, no console warning, no file — so it doesn't look like a bug, it looks like the user mistapped. On iOS the way out is navigator.share({files}), with the caveat that it needs transient user activation, so the Blob-to-File step has to stay synchronous or the sheet is dismissed silently.
Would read the PDF one. Encrypted files are the equivalent trap there: with an owner password the document can't be modified at all, and plenty of tools hand back a corrupted file rather than saying so.