# How In-Browser Image Compression Works with WebAssembly

For years, "compress this image online" meant "upload it to someone's server." That's no longer necessary. Modern browsers can run the same battle-tested C, C++ and Rust codecs that servers use, thanks to WebAssembly. This article walks through how in-browser image compression works, what the main pieces are, and where the limits lie.

### Why do it in the browser at all?

Three reasons come up again and again:

1. **Privacy.** The file never leaves the user's device. There's nothing to store, log or leak on the server side.
2. **No upload latency.** For large photos or big batches, uploading and downloading often takes longer than the compression itself.
3. **Cost and scale.** Image encoding is CPU-heavy. Pushing it to the client means the server only has to serve static files.

The catch is that you inherit the client's hardware, memory and browser quirks. More on that later.

### The built-in option: Canvas

Before reaching for WebAssembly, it's worth knowing what the browser already gives you. You can draw an image onto a `<canvas>` (or `OffscreenCanvas`) and call `toBlob()` / `convertToBlob()` with a MIME type and a quality value:

```js
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
canvas.getContext('2d').drawImage(bitmap, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 });
```

This is fast and needs no extra code, but it has real limits:

- Output formats depend on the browser (JPEG and PNG are universal; WebP and AVIF encoding support varies).
- You get almost no encoder control: one quality number, no chroma subsampling options, no progressive JPEG tuning, no PNG optimization levels.
- Results vary between browsers because each ships its own encoder.

For a serious compressor you want consistent, tunable, best-in-class encoders. That's where WebAssembly comes in.

### Compiling real codecs to WebAssembly

WebAssembly (WASM) is a compact binary format that browsers execute at near-native speed in a sandbox. Existing codec libraries can be compiled to it:

- **MozJPEG** (C) — Mozilla's JPEG encoder, which typically produces smaller files than a baseline libjpeg at the same visual quality, using techniques like trellis quantization and optimized progressive scans.
- **OxiPNG** (Rust) — a lossless PNG optimizer that tries different filters and compression strategies to shrink PNGs without changing pixels. Often paired with a lossy quantizer such as libimagequant when you want palette reduction.
- **libwebp** (C) — Google's reference WebP encoder/decoder, supporting both lossy and lossless modes.
- **libavif** (C), typically with an AV1 encoder like libaom or rav1e — for AVIF, which often achieves strong compression but is slow to encode.

C/C++ libraries are usually compiled with **Emscripten**; Rust crates with `wasm-pack`/`wasm-bindgen`. Google's open-source Squoosh project popularized this approach and is a great reference.

A typical pipeline looks like this:

1. **Decode** the input to raw RGBA pixels (via `createImageBitmap`, or a WASM decoder for formats the browser can't read, such as HEIC or camera RAW).
2. **Process** — resize, crop, rotate, watermark — usually on raw pixel buffers.
3. **Encode** the pixels with the chosen WASM encoder and settings.
4. **Return** a `Blob` the user can download, entirely client-side.

Data crosses the JS/WASM boundary as bytes in WASM linear memory: you copy the pixel buffer in, call the encoder, and copy the output bytes back out.

### Keep the UI responsive: Web Workers

Encoding a 24-megapixel photo with MozJPEG or AVIF can take from hundreds of milliseconds to many seconds. Do that on the main thread and the page freezes. The standard answer is **Web Workers**:

```js
// main.js
const worker = new Worker(new URL('./encode-worker.js', import.meta.url), { type: 'module' });
worker.postMessage({ pixels, width, height, options }, [pixels.buffer]);
worker.onmessage = (e) => saveBlob(e.data);
```

A few practical points:

- **Transfer, don't copy.** Passing `ArrayBuffer`s in the transfer list moves ownership to the worker without duplicating megabytes of data.
- **Use a worker pool for batches.** `navigator.hardwareConcurrency` gives a hint for how many workers to spin up. Too many and you'll run out of memory before you run out of CPU.
- **Multithreaded WASM** (WASM threads with `SharedArrayBuffer`) can speed up encoders like AVIF, but requires the page to be cross-origin isolated (`Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers), which can break embedding of third-party resources. Many apps ship a single-threaded fallback.
- **SIMD** support in WASM is widely available in modern browsers and gives meaningful speedups for codec inner loops; feature-detect it and load the right build.

### Memory: the real constraint

Raw pixels are big. A 6000×4000 image as RGBA is 6000 × 4000 × 4 ≈ 96 MB — before the encoder allocates its own working buffers. Things to keep in mind:

- 32-bit WebAssembly has a 4 GB address space ceiling, but practical limits are often much lower, especially on mobile browsers and older devices, where tabs can be killed under memory pressure.
- WASM memory can grow but not shrink. A long-lived worker that processed one huge image may keep that memory reserved. Terminating and recreating workers between large jobs is a simple way to release it.
- For batches, limit concurrency by estimated memory (width × height × 4 × some overhead), not just by CPU count.
- Free intermediate `ImageBitmap`s (`bitmap.close()`) and revoke object URLs you no longer need.

### Trade-offs vs. server-side compression

In-browser compression isn't strictly better; it's a different set of trade-offs.

**Client-side wins:**
- Files never leave the device — a strong privacy story.
- No upload/download time; works offline once loaded (especially with a service worker).
- Near-zero marginal server cost.

**Server-side wins:**
- Predictable performance on dedicated hardware; slow phones aren't your problem.
- No large WASM bundles to download up front (codec binaries can be hundreds of KB to a few MB each, so lazy-load them per format).
- Easier to run in automated pipelines (CI, CMS uploads, CDNs).
- Full control over codec versions and native multithreading without browser header constraints.

A reasonable rule of thumb: for user-facing, ad-hoc compression where privacy matters, the browser is a great fit. For automated, high-volume asset pipelines, server-side or build-time tools still make sense.

### Putting it into practice

I've been building [Zippic](https://zippic.app), a free image compressor and converter that applies this approach: everything is processed in the browser with WebAssembly, with no uploads and no sign-up. It handles JPG, PNG, WebP, GIF, AVIF, BMP and RAW, plus batch compression and a few conversion tools. If you try it, I'd genuinely like to hear how it compares with server-based tools on your images.

### Wrapping up

The ingredients for serious in-browser image compression are all there: mature codecs compiled to WebAssembly, Web Workers for responsiveness, and careful memory management. Start with Canvas for simple cases, move to WASM codecs when you need quality and control, and design around memory from day one. Your users' files can stay on their own machines.
