DEV Community

Cover image for I reverse-engineered browser image compression — here's the actual code
Saba Khan
Saba Khan

Posted on

I reverse-engineered browser image compression — here's the actual code

I had 200 product photos to compress for a client's e-commerce site. The Photoshop batch processor worked, but launching a 2 GB application to run one command felt like using a flamethrower to light a candle. I wanted something that ran directly in the browser — no installs, no uploads to some random server.

How browser image compression actually works

There are three real approaches:

Approach How it works Upside Downside
Canvas API Draw image to <canvas>, export with toBlob() Zero dependencies, works everywhere Limited to Canvas-supported formats
WebAssembly Compile libjpeg-turbo or libwebp to WASM Full control over compression params Heavy bundle (~500 KB), complex setup
OffscreenCanvas + Workers Do Canvas compression on background threads Non-blocking UI, batch-friendly Browser support still patchy

I picked Canvas. For 90% of real-world cases — product photos, blog images, user uploads — it handles the job without adding a 500 KB WASM binary to your bundle.

The code

Here is the core function:

function compressImage(file, { quality = 0.7, maxWidth = 1920, format = 'image/webp' } = {}) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onerror = reject;
    reader.onload = (e) => {
      const img = new Image();
      img.onerror = reject;
      img.onload = () => {
        let { width, height } = img;
        if (width > maxWidth) {
          height = Math.round(height * (maxWidth / width));
          width = maxWidth;
        }

        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;

        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);

        canvas.toBlob(
          (blob) => {
            if (!blob) return reject(new Error('toBlob returned null'));
            resolve(blob);
          },
          format,
          quality
        );
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  });
}
Enter fullscreen mode Exit fullscreen mode

Wiring it up to a file input:

const input = document.querySelector('#image-input');

input.addEventListener('change', async (e) => {
  for (const file of e.target.files) {
    const originalSize = file.size;
    const compressed = await compressImage(file, { quality: 0.6, format: 'image/webp' });

    console.log(
      `${file.name}: ${(originalSize / 1024).toFixed(0)} KB → ${(compressed.size / 1024).toFixed(0)} KB ` +
      `(${((1 - compressed.size / originalSize) * 100).toFixed(0)}% smaller)`
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

A bug I hit: the PNG default trap

canvas.toBlob(callback) without a format argument defaults to image/png. PNG is lossless — your "compressed" image ends up larger than the original JPEG. I spent 20 minutes wondering why my 2 MB file turned into a 4 MB blob.

Always pass the format explicitly. 'image/webp' is the right call in 2026 — every browser supports it now.

Batching without freezing

For multiple files, fire them all at once with Promise.all:

async function compressBatch(files, opts) {
  const blobs = await Promise.all(
    Array.from(files).map(f => compressImage(f, opts))
  );
  // blobs ready — download individually or pack into a zip
  return blobs;
}
Enter fullscreen mode Exit fullscreen mode

For 100+ images, move the canvas work into a Web Worker with OffscreenCanvas. The API looks almost identical but runs off the main thread. No more frozen tabs.

Real numbers

I grabbed 50 product photos from an e-commerce catalog (average 2.4 MB JPEG, 3000×3000 px):

Quality Format Avg output Reduction Visual quality
0.9 JPEG 310 KB 87% Same as original on screen
0.7 JPEG 145 KB 94% Slightly softer edges at 200% zoom
0.7 WebP 98 KB 96% Matches JPEG 0.9 to the naked eye
0.5 WebP 62 KB 97% Fine for thumbnails, too aggressive for product pages

Total time for 50 images: 4.2 seconds (Chrome 126, M1 Mac). That is under 85 ms per image.

For the e-commerce project, WebP at quality 0.7 was the clear winner. 96% smaller files, zero visible difference on a 27-inch display.

What the Canvas approach cannot do

PDF compression, background removal, and format conversion (PNG to SVG, etc.) all need different pipelines. You can build those with WebAssembly, but that belongs in a separate article.

What I took away from this

  1. Browser image compression works better than most people assume. You do not need a server, a library, or Photoshop. Roughly 50 lines of JavaScript gets you 96% compression with no visible quality loss.

  2. canvas.toBlob() without a format argument will burn you. Always pass 'image/webp' or 'image/jpeg' explicitly.

  3. Web Workers make batch processing genuinely usable on 50+ images without freezing the tab.

I put all of this into a tool at ComprimeFotos. The UI is in Spanish (I originally built it for a Spanish-speaking audience), but the tool itself needs no translation — drag an image, it compresses, you download. No signup, no ads, no uploads.

Top comments (0)