Private Image Compression in the Browser: WebAssembly Codecs, and the AVIF That Wouldn't Ship
A bulk image compressor that runs entirely in your browser, nothing uploaded. How the WebAssembly codecs and a worker fit together, why AVIF and parallel PNG optimization broke the build, and how I made PNG actually shrink.
Most online image compressors ask you to upload your photos to their server. For something I would run on my own screenshots and client work, that is exactly backwards. I wanted Squish to compress in bulk and never send a single byte anywhere. That one constraint, everything stays on the device, is what made it interesting, because it means the actual codecs have to run in the browser. Here is how that fits together, and the two walls I hit getting there.
Everything Runs in the Browser, Nothing Uploaded
The pipeline is the same one a server-side tool would use, just relocated into the tab. To shrink an image you have to decode it to raw pixels, then re-encode it with a better codec. The browser already decodes anything it can display, so I lean on that:
// decode with the browser, draw to an OffscreenCanvas, read the pixels
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(width, height)
const ctx = canvas.getContext("2d")
ctx.drawImage(bitmap, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height)Re-encoding is the hard part, and that is where WebAssembly comes in. The jSquash project ships the real WebP, JPEG, and PNG encoders compiled to WASM, so I can produce a genuine WebP in the browser, not a canvas approximation:
import encodeWebp from "@jsquash/webp/encode"
const out = await encodeWebp(imageData, { quality })All of this runs in a Web Worker, not on the main thread, so compressing a batch of twenty images does not freeze the UI. The page hands the worker a file and gets back compressed bytes. The upshot is the privacy promise is real, not a marketing line: there is no server in the path at all, so your images cannot leave even if I wanted them to.
The Codec That Wouldn't Ship
The best modern codec is AVIF. It is smaller than WebP at the same quality, and jSquash offers it, so it should have been a free win. It was not.
AVIF's encoder is heavy, and its WebAssembly build is multithreaded: it uses a parallel build that spawns its own nested workers (via Rust's rayon). Two problems followed. First, multithreaded WASM needs special cross-origin isolation headers (COOP and COEP) to even run in a browser, which constrains the whole site. Second, and the one that actually stopped me, those nested workers hung the production build indefinitely. The bundler would sit there forever trying to trace the worker graph. I lost a long CI run to it before I understood what was happening. The parallel PNG optimizer (oxipng) had the same defect for the same reason.
The fix was to stop fighting it: ship only the single-threaded codecs (WebP, JPEG, PNG), which bundle cleanly and need no special headers. AVIF still works as an input (the browser decodes it natively in that first step), it just is not an output option. The lesson stuck: single-threaded WASM is a quiet dependency, multithreaded WASM is a deployment decision that touches your whole app.
Why My PNGs Barely Shrank
For a while, PNG output was almost pointless. Run a screenshot through it and it shrank a little; run a photo through it and it got bigger. The quality slider did nothing.
That is not a bug, it is what PNG is. PNG is lossless: it predicts each pixel from its neighbors and stores the difference, then DEFLATEs the result. That is wonderful for flat regions and sharp edges (logos, UI, screenshots) and useless for photographs, where every pixel differs slightly from the last and there is nothing to predict. My first version used the lossless PNG encoder and stopped there, so "quality" had no meaning for it.
The real win for PNG is quantization: reduce the image to a smart palette of a few hundred colors, the same trick pngquant and TinyPNG use. So below full quality I switched the PNG path to a quantizing encoder and mapped the slider to a color count:
// quality 100 stays truly lossless; below that, quantize the palette
const colors = quality >= 100 ? 0 : Math.round((quality / 100) * 256)On real screenshots that turned "8 percent smaller" into "75 to 85 percent smaller." Crucially I used a pure-JavaScript quantizer rather than a parallel WASM one, so it did not reintroduce the build hang from the last section.
Letting the Tool Pick the Format
Once PNG quantization landed, a subtler problem showed up. If you force one format on a mixed batch, some images lose. Force PNG on a folder of photos and they balloon, because PNG is the wrong tool for a photograph.
So I added two things. An Auto mode encodes each image as both WebP and quantized PNG and keeps whichever is smaller, returning which it chose. And on any image whose output grew, the row offers a one-click nudge:
Switch to WebP, 81% smaller
It only runs the extra encode on images that actually grew, and the number shown is a real WebP encode, not a guess. It turns the classic footgun (a photo sitting in PNG, larger than the original) into a single click.
What I'd Tell Someone Building This
If you want real codecs in the browser, WebAssembly is the answer, but prefer the single-threaded builds. The multithreaded ones promise more speed and cost you cross-origin headers and, in my case, a build that never finished. Do the heavy work in a worker so the UI stays alive. Match the format to the image rather than trusting one global setting, because the best format genuinely depends on the picture. And remember the biggest lever of all is often not the codec at all: halving an image's dimensions quarters its pixels before compression even starts.
The result is a compressor that is free, instant, and private by construction. Nothing uploads, nothing is throttled, and the only machine doing the work is the one already looking at the picture.
Related posts
Building a Desktop YouTube Downloader: Cookies, the n-Challenge, and Zero-Install
- Published on
Giving a Portfolio Assistant Real Tools (and Five Models So It Never Goes Down)
- Published on
Launching a 10K NFT Collection: Gas-Optimised Minting at Scale
- Published on
Streaming AI Chat UIs with the Vercel AI SDK and Provider Switching
- Published on