1
0 Comments

How I built a browser-based image compressor with Rust and WASM (and why nothing gets uploaded)

I've been building [zipo.pics](https://zipo.pics) for a few months. It's an image compressor that runs entirely in your browser — no uploads, no server processing your files.

This started because I kept hesitating before uploading images to compressor sites. Nothing dramatic, just that small pause of "wait, where is this going." Compressing a file is a local operation. It shouldn't require trusting a stranger.

So I built it to actually be local. Here's the part that was harder than I expected.

### wasm32-unknown-unknown has no libc

I did not know this going in, and it shaped basically every decision after.

There's no malloc. No free. No qsort, no bsearch, no <stdlib.h>. Which matters a lot when every serious image codec is written in C.

My first pick for JPEG was mozjpeg — it's the one everyone recommends. It calls libc::free and libc::fdopen. Neither exists on this target. The build fails at link time and that's it, there's no workaround I could find. I switched to jpeg-encoder, pure Rust. Plain baseline JPEG, works everywhere, just not as tight.

oxipng was the same story. It's genuinely better at lossless PNG than the png crate, but libdeflater is a required dependency and it's C. So PNG goes through imagequant for quantization and then the png crate's own DEFLATE.

For WebP I didn't want to give up libwebp — the ratio is meaningfully better than the alternatives. So I wrote a shim: malloc/`free`/`calloc`/`realloc` plus qsort and bsearch, dlmalloc underneath, behind #[cfg(target_arch = "wasm32")]. Plus emscripten's clang and sysroot so <stdlib.h> actually resolves. That one does work, and it's the piece I'm least proud of maintaining.

AVIF went through ravif (rav1e), which is pure Rust and compiled with zero drama. Genuinely the easiest of the four.

### The thing that surprised me

I assumed AVIF would win on size everywhere. It doesn't.

On a small 128×128 gradient I tested — 50.9KB source — here's what came out:

| Format | Size |

|---|---|

| PNG (optimized) | 12.8KB |

| JPEG | 2.1KB |

| WebP | 0.9KB |

| AVIF | 1.3KB |

AVIF came out bigger than WebP. That's not a bug. On small, smooth images there isn't enough complexity for AVIF's predictors to pay off, and its container overhead is proportionally higher. On real photographs at full resolution it beats WebP comfortably. But "AVIF is always smallest" is just wrong, and I only found out because I tested on something small first.

Small sample, synthetic input — I'm not claiming this generalizes. But it changed how I think about recommending formats, and it's why I stopped assuming.

### Two things that cost me real time

wasm-pack runs wasm-opt by default, and the bundled version doesn't understand bulk-memory instructions that current Rust emits. The error you get is completely unhelpful. --no-opt skips it. Browsers support those natively so nothing is actually lost — but that was an afternoon.

The other: WASM runs synchronously. On the main thread, one large image locks the tab completely. No painting, no input, nothing until the Rust function returns. A Web Worker isn't an optimization here, it's mandatory. Everything runs in one.

### Where it is now

Four formats work. The whole WASM bundle is about 2.1MB. It's live at [zipo.pics](https://zipo.pics), running on Cloudflare Pages with a Bun + Hono + SQLite backend for accounts and credits (compression itself never touches it).

Monetization is pay-per-compression credits — free credits on signup, then packs at $4.90 / $12.90 / $29.90. Honestly still figuring out whether that's the right model; the category is full of free tools, so the conversion question is the thing I'm least sure about.

What I'm not winning on: raw compression ratio. Native tools with full C libraries beat me. That's the cost of not uploading your file, and I think it's worth it, but I'm not going to pretend the tradeoff isn't there.

Biggest open question right now is AVIF speed. It's noticeably slower than WebP — fine for one image, annoying for fifty. I've been going back and forth on whether to optimize the encoder path or just be upfront about when to use WebP instead.

If you've shipped anything WASM-heavy to production, I'd genuinely like to hear how you handled the C dependency problem. And if anyone has actually gotten mozjpeg onto wasm32-unknown-unknown, I gave up on that one.

posted toAvatar for product zipo.pics
zipo.pics