DEV Community

Umasou
Umasou

Posted on

Brotli for WASM and ONNX Models: Cutting First-Run Downloads From 72MB to 13MB, and the Compatibility Call We Made on Purpose

We build ClearPix, a set of media cleanup tools that run entirely in the browser. That means every engine we ship — the ONNX Runtime WebGPU backend (a 27MB jsep.wasm plus a 14MB core), the ffmpeg core for video (30.7MB), and the model zoo behind our inpainting, upscale, and detection tools (up to a 198MB LaMa) — is downloaded into a stranger's browser tab before anything useful happens. The download path is the product experience. This post is about the delivery layer we ended up with: pre-compressed brotli objects on Cloudflare R2, why we compress WASM hard and ONNX barely, the progress-bar bug that fell out of it, and the compatibility decision we made consciously instead of by default.

What actually constrains model delivery

Three hard facts shaped everything:

  1. Cloudflare Pages has a 25MB per-file limit. Our biggest files (27–198MB) can't even live in the deploy bundle. The build strips them, so a "mirror chain" isn't an optimization — it's the only way the product works at all.
  2. Public mirrors are a fallback, not a source. We learned this in production on 2026-09-26: huggingface.co was unreachable from some networks, and hf-mirror's CORS headers were inconsistent (sometimes Access-Control-Allow-Origin, sometimes nothing), so model downloads simply failed for real users. Everything large now lives in an R2 bucket we control — 10GB free, zero egress fees, one controllable origin.
  3. The fallback path gets real traffic. For the ORT runtime binaries we probe jsDelivr first (measured roughly 7× the throughput of our R2 custom domain) and fall back to R2 when the CDN is blocked — which happens in entire network regions. So the R2 path is not a theoretical safety net; for a slice of users it is the path. We measured our own access too: the default pub-*.r2.dev URL crawls at 75–110 KB/s, while a same-zone custom domain (static.clearpix.org) sustains ~2.3 MB/s — a 20–30× difference that has nothing to do with compression.

Brotli: compress at upload, not at request time

R2 doesn't transcode on the fly. You store bytes; it serves bytes. So we compress at upload time in the setup script, using nothing but Node's built-in zlib:

const br = brotliCompressSync(readFileSync(p), {
  params: { [BROTLI_PARAM_QUALITY]: 11 },
});
// wrangler r2 object put ... --content-encoding br --content-type application/wasm
Enter fullscreen mode Exit fullscreen mode

The object key doesn't change, Content-Type stays application/wasm, and the browser transparently decodes Content-Encoding: br before the bytes ever reach ONNX Runtime or our model loader. Zero runtime changes — the URLs are identical, SHA-256 pins validate the decoded bytes, and IndexedDB caches the decoded bytes.

Measured ratios at quality 11, on our real files:

File Raw Brotli Ratio
ort-wasm-simd-threaded.jsep.wasm 27.0 MB 3.8 MB 14%
ort-wasm-simd-threaded.wasm 14.2 MB 2.2 MB 16%
ffmpeg-core.wasm 30.7 MB 6.9 MB 22%
migan.onnx (inpainting) 28.2 MB 23.1 MB 82%
yolo11_watermark_det.onnx 10.6 MB 8.8 MB 84%
lama_fp32.onnx 198.4 MB 179.2 MB 90%
real_esrgan_x4.onnx 63.9 MB 58.6 MB 92%
ppocr_v5_mobile_det.onnx 4.5 MB 4.2 MB 93%

The pattern is physics, not tuning: WASM is code — structured, repetitive, full of zeros — and crushes to 14–22%. ONNX models are mostly float32 weights, which are close to entropy-maximal noise; brotli shaves 8–18% and that's the ceiling. (Worth knowing before you promise "50% smaller models" somewhere.) The full archive, all ten files, drops from 384MB to 293MB — but the number users feel is the first-run set: ORT WASM + ffmpeg went from 71.9MB to 13.0MB, an 82% cut on the critical path. Upload-side cost is a one-time ~10 minutes of q11 compression; quality 11 matters for a file served a million times, not for one.

The bug this introduced: progress bars that lie

Compression broke one thing, and it's worth calling out because it's subtle. When a response carries Content-Encoding: br, the Content-Length header is the compressed size, but a streaming reader in JavaScript receives decoded bytes. Our model loader's progress bar counted decoded bytes against the compressed total — so a 198MB model served as 179MB of brotli would blow straight past "100%" and keep climbing.

The fix is three lines: if Content-Encoding is present, ignore Content-Length and use the spec's known original size (expectedSize) as the denominator. Decoded bytes over original size — progress is honest again. Integrity checking was never affected: we hash the decoded bytes against pinned SHA-256s, so a compressed mirror and an uncompressed mirror are interchangeable.

The decision: br-only, no negotiation, on purpose

Here's the call we get asked about. An object uploaded with content-encoding: br is served that way to every request — R2 stores the encoded bytes and doesn't do content negotiation. A client that can't decode brotli gets undecodable bytes. We could have stored two variants (identity + .br) and put a ~50-line Worker in front of the custom domain to switch on Accept-Encoding, with Vary headers and doubled storage.

We didn't. The reasoning, in one table:

Brotli support Our runtime baseline
Chrome 50 (2016) WebGPU since 113 (2023)
Firefox 44 (2016) WebGPU since 141 (2025)
Safari 11 (2017) WebGPU since 26 (2025)
Edge 15 (2017) 113 (2023)

A browser that can't decode brotli is from 2016 or earlier. A browser that can run ONNX Runtime Web with WASM SIMD and WebGPU is from 2023 or later. The intersection of "can't decode br" with "can boot our app" is empty — the fallback we'd be building serves users who are stopped two checkpoints earlier anyway. Building dual-variant + negotiation to protect a theoretical client is solving a problem we don't have, at the cost of a Worker in the request path and an ops surface we have to maintain forever. If that ever changes — say, a genuinely non-br embedded client appears — the dual-variant design is a clean add-on, and we wrote down exactly how we'd do it.

The honest caveat, for your own tooling rather than users: anything that fetches from R2 with plain curl needs brotli support (curl --compressed), or it saves compressed bytes to disk. Fine for scripts, worth documenting.

One thing we still don't love: ORT WASM has no persistent cache

Our ONNX models and ffmpeg core have a two-level cache: immutable HTTP headers plus IndexedDB with self-healing SHA-256 verification — download once, keep forever, survive releases. The ORT runtime WASM is different: ONNX Runtime fetches it internally, so we can't feed it from IndexedDB, and its only cache layer is the browser's HTTP cache. Mobile browsers evict that under storage pressure, and our release cadence happens to match the eviction cadence — which is why "every release makes users re-download the WASM" is an illusion we'd like to kill properly with a Service Worker and Cache Storage. That's the next post in this layer of the stack.

Try it

Everything described here runs in production: the dual-engine MI-GAN/LaMa watermark remover at clearpix.org/remove-watermark (the 198MB LaMa is the opt-in HQ engine — never prefetched), the Real-ESRGAN upscaler at clearpix.org/upscale-image, and the WebCodecs/ffmpeg pipelines that motivated the ffmpeg core over at clearpix.org/remove-video-watermark. Open devtools on any of them: the only bytes on the wire are the engines and models themselves.


Part 9 of the ClearPix engineering series — how we build free, private, in-browser media tools at clearpix.org.

Top comments (0)