# How I built a browser-side background remover (and benchmarked Canvas vs WebAssembly)

> Source: <https://dev.to/saba_khan_50d6d6e112c484f/how-i-built-a-browser-side-background-remover-and-benchmarked-canvas-vs-webassembly-5bf2>
> Published: 2026-06-25 09:58:00+00:00

I had 300 product photos sitting in a folder. White backgrounds, mostly. I needed them on transparent backgrounds for a client's Shopify store. The obvious move: upload them to some online background remover. Five minutes, done.

Then I thought about it. These were unreleased product shots. Uploading them to a random server felt wrong. Plus, I'd have to do this every month when new products came in. I wanted something that ran locally.

Browsers can do this now. The question was how well.

There are two practical ways to remove backgrounds in the browser without sending pixels to a server:

**Canvas API pixel bashing.** You load the image onto a `<canvas>`

, grab the pixel data with `getImageData()`

, and manually set alpha values. Pick a reference color from the background, calculate each pixel's distance from it, threshold it. This is fast and needs zero dependencies. But it only works on uniform backgrounds.

**WebAssembly + ML model.** You compile a segmentation model to WASM, load it, and run inference in the browser. ONNX Runtime Web makes this practical. It handles hair, fur, complex edges — but you're downloading an 8+ MB model and burning more CPU.

I built both and ran them through 500 test images. Here's what happened.

The code is straightforward. Load the image, sample a pixel from a corner (assuming that's the background), and set alpha to zero for every pixel within a threshold:

``` js
function removeBackgroundCanvas(image, threshold = 40) {
  const canvas = document.createElement('canvas');
  canvas.width = image.width;
  canvas.height = image.height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0);

  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;

  // Sample background from top-left corner
  const bgR = data[0], bgG = data[1], bgB = data[2];

  for (let i = 0; i < data.length; i += 4) {
    const dr = data[i] - bgR;
    const dg = data[i + 1] - bgG;
    const db = data[i + 2] - bgB;
    const distance = Math.sqrt(dr * dr + dg * dg + db * db);

    if (distance < threshold) {
      data[i + 3] = 0; // Set alpha to 0
    }
  }

  ctx.putImageData(imageData, 0, 0);
  return canvas;
}
```

This works surprisingly well on studio-lit product photos. A white or light-gray background gets nuked in about 15 milliseconds per image on my laptop. No dependencies, no loading spinners, no model downloads.

The problem: as soon as the background isn't uniform — a gradient, a textured wall, someone's shirt that's close to the wall color — it falls apart. You get jagged edges, halos, or chunks of the subject vanishing.

For real images, you need a model that understands what's foreground and what's not. MediaPipe's selfie segmentation model runs in the browser and can be loaded via ONNX Runtime Web:

```
import * as ort from 'onnxruntime-web';

async function removeBackgroundML(image) {
  const session = await ort.InferenceSession.create('model.onnx');

  // Preprocess: resize to model input size, normalize
  const tensor = preprocessImage(image, 256, 256);

  const results = await session.run({ input: tensor });
  const mask = results.output.data; // Float32Array, 256x256

  // Apply mask as alpha channel
  return applyMaskToImage(image, mask);
}
```

The catch: the model file is 8.3 MB. First load takes about 1.2 seconds on a fast connection. Inference takes roughly 180-220 milliseconds per image. You're also pulling in `onnxruntime-web`

which adds about 2 MB to your bundle.

But the output is dramatically better. Hair strands, fur, transparent objects — things the Canvas approach can't touch — get handled reasonably well.

I ran 500 images through both approaches on a 2023 MacBook Pro (M2, 16 GB RAM):

| Metric | Canvas | WebAssembly |
|---|---|---|
| Time per image | 12-18 ms | 180-220 ms |
| Model load time | 0 ms | 1,100 ms |
| Memory peak | 24 MB | 180 MB |
| Works on uniform bg | Yes | Yes |
| Works on complex bg | No | Yes (mostly) |
| Handles hair/fur | No | Yes |
| Bundle size added | 0 KB | ~10 MB |

The Canvas approach is basically free — you're already paying for image decoding, and the pixel loop runs at native speed once the JIT kicks in. On 500 images, total processing time was under 8 seconds.

The WebAssembly approach took about 95 seconds for the same batch, plus the initial model download. But it handled 412 out of 500 images correctly, versus 187 for Canvas.

I combined both. The tool tries the Canvas approach first. If more than 30% of edge pixels end up partially transparent — a sign of a non-uniform background — it falls back to the ML model. This hybrid approach averages about 40 ms per image on typical product photo batches.

If you need [a browser-based background remover](https://comprimefotos.com/tools/quitar-fondo) that handles both simple and complex images, this one runs entirely on your machine. The UI is in Spanish, but drag-and-drop needs no translation.

A few things I learned that aren't obvious from the docs:

** getImageData() triggers a GPU-to-CPU readback on most browsers.** If you're processing multiple images in sequence, batch your Canvas operations before reading pixels back, or you'll pay the sync cost every time.

**ONNX Runtime Web has two backends: wasm and webgl.** The WebGL backend is 3-5x faster for inference but only works if WebGL is available. Always check

`ort.env.wasm.numThreads`

and set it to `navigator.hardwareConcurrency`

— otherwise you'll leave cores idle.**For product photos, 256x256 model input is enough.** Going to 512x512 buys you noticeably better edges but roughly 4x the inference time. Not worth it unless you're doing professional retouching.

If you just need to quickly strip a background from a photo, try the Canvas approach first. It's simpler than you think. If it doesn't work, the browser can run a real ML model now — you just need to wait a second.
