# Removing a Photo's Background in the Browser, With No Upload: AI Licenses, ONNX Models, and a Frozen Tab

> Source: <https://dev.to/androve2k/removing-a-photos-background-in-the-browser-with-no-upload-ai-licenses-onnx-models-and-a-1cc0>
> Published: 2026-07-24 12:36:06+00:00

I wanted to add a background-removal tool to my site's image cluster that stayed true to the 100% client-side processing principle I already use for PDFs and image conversions. The path there was anything but linear: a library dropped over a licensing problem, a carefully chosen model that turned out more limited than expected, and a bug that froze the *entire* page — not just the tool — during computation.

Here's the full build, including the parts that didn't work the first time.

The initial idea was broad: remove backgrounds, and maybe unwanted objects too. The two tasks have very different difficulty levels.

Removing objects requires inpainting — plausibly reconstructing the erased area — which in practice still means heavy generative models, impractical to run client-side with good quality on an average device.

Removing a *background*, on the other hand, is a segmentation problem: separating a subject from its surroundings. That has much lighter models available, runnable entirely via WebAssembly with no server involved at all.

So: background removal only, object removal shelved for later.

The first library that looked like a perfect fit turned out to be distributed under **AGPL**, a strong copyleft license. Free to use — but with a real catch for anyone embedding it in a public, closed-source web service: AGPL can require releasing the full source of the project that embeds it, under the same license.

"Free for the end user" and "safe to drop into a closed-source commercial product" are two different questions, and it's worth answering the second one *before* writing integration code, not after deploying it.

Before wiring any "free" AI library into a commercial project, check the exact license, not just the price tag. AGPL, GPL, and other strong copyleft licenses are fine for personal or internal tools, risky for a public closed-source product.

The fix: switch to **Transformers.js** — Hugging Face's library for running ML models in the browser on top of ONNX Runtime Web — with a permissively licensed model (Apache-2.0) instead of the AGPL wrapper. Same principle (an ONNX model pulled once from a CDN, then cached by the browser), clean license.

Picking the model taught me a lesson that generalizes to any browser-ML project: **architecture matters more than file size**.

A transformer-based model, even a qualitatively great one with a permissive license, can crash from memory exhaustion during WASM inference on a lot of machines. Transformer attention scales memory with the number of image "patches," and at normal photo resolution the intermediate tensors get huge. A smaller CNN with an architecture built for segmentation runs far more reliably in WASM.

| Layer | Choice |
|---|---|
| Runtime | Transformers.js on top of ONNX Runtime Web (WASM) |
| Default model | Light CNN, Apache-2.0, optimized for people |
| Optional model | Heavier transformer, MIT, more general-purpose |
| Processing | 100% local, no image upload |
| Cache | Browser + service worker, one-time download |

The model often left a residual color halo around the cutout, because the output is a *soft* alpha matte rather than a clean binary mask — background pixels could carry residual alpha values of 5–15% instead of a clean 0.

No second model needed. Just post-process the alpha curve: force low values to full transparency, force high values to full opacity, use a smoothstep transition in between so soft edges (hair, fine detail) survive. On a test image this pushed over 60% of pixels to fully transparent and over 35% to fully opaque, leaving only a small sliver in the mid-range — a clean result.

```
// Simplified alpha cleanup
function cleanAlpha(a, lowT = 0.08, highT = 0.85) {
  if (a <= lowT) return 0;
  if (a >= highT) return 1;
  const t = (a - lowT) / (highT - lowT);
  return t * t * (3 - 2 * t); // smoothstep
}
```

First real-world tests were disappointing on two fronts: a flat illustration (the model only trimmed the margin around the shape, leaving the whole drawing opaque) and a dim, cluttered indoor photo (the model nearly erased the main subject too).

To isolate the cause, I temporarily added a debug panel showing the model's *raw* mask, before any alpha cleanup, next to the final result. They were nearly identical — so the bug wasn't in my post-processing, it was upstream, in the model itself.

Checking the model card confirmed it: the default model was trained specifically on a **human segmentation dataset**, not a general-purpose one. On a well-lit, close-up portrait, the cutout is genuinely clean, hair included. On anything far from that training domain — illustrations, dark scenes, cluttered framing — quality drops in a predictable, not-a-bug way.

A model that's "light and WASM-reliable" is often exactly that

becauseit's trained on a narrow domain. Before calling a model "bad," check what it was actually trained on — the mismatch is usually domain, not quality.

Instead of replacing the light model with a heavier, more general one for *everyone* — making every user pay the load-time cost for a case they may never hit — I added a second model (an MIT-licensed transformer, better suited to generic scenes) available only on request. A "Try the better model" button appears after the first result and reprocesses the same image, without overwriting the existing output until the heavier model succeeds. If it fails from insufficient memory, the first result stays intact and the user gets a clear error instead of a broken tool.

Light by default, heavy on explicit request — nobody pays for a download they'll never use.

Testing the heavier model surfaced something much worse than mediocre output: during processing, the *entire page* stopped responding — not just the tool, the top nav links too.

The cause: by default, ONNX Runtime Web runs WASM computation on the browser's **main thread**, the same one handling clicks, scroll, and rendering. During heavy inference that thread stays busy and the whole UI freezes.

First attempt at a fix — flipping an internal library option meant to delegate computation to a separate thread — didn't work, likely due to delicate initialization timing. The reliable fix was writing and controlling a real **dedicated Web Worker** myself: a separate thread that loads the library, downloads the model, and runs the whole inference, talking to the main page only through `postMessage`

(input image in, progress and result out).

``` js
// worker.js (simplified)
self.onmessage = async (e) => {
  const { imageBuffer } = e.data;
  const { pipeline } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers');
  const remover = await pipeline('background-removal', MODEL_ID, { device: 'wasm' });
  const result = await remover(imageBuffer);
  self.postMessage({ type: 'done', result }, [result.buffer]);
};
```

This way the main thread never does heavy computation *by construction*, not by a configuration flag I was hoping would hold.

If client-side AI processing freezes the whole interface, not just the component using it, the top suspect is main-thread computation. A config flag might not be enough — a dedicated, explicitly written Web Worker is the more reliable fix.

Even with the freeze fixed, there was a perception problem left: the heavier model takes roughly two minutes on common hardware, and the library doesn't expose real percentage progress during inference — only during the model download.

A progress bar sitting "full and still" for two minutes reads as *broken*, not *slow*.

Final approach: a bar that advances on an estimated curve over ~2.5 minutes, with elapsed seconds shown below, and — if processing runs past that estimate — an automatic switch to an indeterminate animation (a continuously scrolling stripe), the universal "still working" signal without faking a percentage the model can't provide. A wider safety timeout unlocks the UI if something really goes wrong, so the user can retry.

The most useful lesson isn't about one bug — it's about ordering priorities:

If you've hit the AGPL-vs-permissive-license question before, or fought with `onnxruntime-web`

blocking a UI thread, I'd be curious to hear how you solved it.

*I write about building and maintaining roversia.it — a personal site with 35+ browser tools, PWA games, and small web apps, all vanilla JS, zero monthly cost, and (as much as possible) zero server-side processing.*
