DEV Community

swifttooly
swifttooly

Posted on

Compressing images in the browser with the Canvas API (no server needed)

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:

  1. Read the user's file with the File API
  2. Draw it onto a <canvas>
  3. Re-export the canvas as a Blob at a lower quality
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)