Real-world compression numbers for WebAssembly image codecs running entirely in the browser — measured while building Convertilo, a free privacy-first image/PDF/text toolkit (no upload, all processing local).
The point of this repo is to save you the days of trial-and-error I spent wiring these codecs together. Numbers are from a 100-image corpus (photos + screenshots + UI assets, mixed sizes 200 KB – 8 MB) at quality 75 unless noted.
| Format | Engine | Mode | Reduction | Notes |
|---|---|---|---|---|
| JPEG | MozJPEG via @jsquash/jpeg |
lossy q75 | ~53% | Competitive with server-side TinyPNG. |
| PNG | imagequant + @jsquash/png |
lossy q75 (quantize) | ~75% | Quantization down to 256 colours. |
| PNG | OxiPNG via @jsquash/png |
lossless q100 | varies | Use this branch when quality=100; gains depend on input. |
| WebP | libwebp via @jsquash/webp |
lossy q75 re-encode | ~17% | Re-encoding already-optimized WebP is weak (see below). |
| AVIF | libavif via @jsquash/avif |
lossy q75, speed=6 | ~59% | Best size/quality ratio of the lossy formats here. |
| GIF | gifsicle-wasm-browser |
static -O3 --lossy |
~75% | |
| GIF | gifsicle-wasm-browser |
animated (all frames preserved) | ~27% | No frame drops; weight comes from optimization, not lossy frame culling. |
| SVG | SVGO v4 browser bundle | multipass | ~42% | Pure XML transform — fastest in the table. |
These are median values across the corpus; per-image results vary widely.
These are non-obvious things I burned hours figuring out. Drop them into your own integration without the bruises.
The intuition is "lossy quantization should help any lossy codec". It doesn't.
Pre-quantizing the bitmap before feeding it to libwebp introduces dither
patterns that fight with WebP's perceptual model — measured compression went
from -17% to -12%. Use imagequant only with PNG output (lossless container).
If you await decode(buffer) on a malformed AVIF, @jsquash/avif returns
null rather than throwing. Wrap with an explicit guard or the rest of your
pipeline will read .data off null and crash with a confusing stack trace
three calls deep:
async function safeDecodeAvif(buf: ArrayBuffer): Promise<ImageData> {
const result = await decode(buf)
if (!result) throw new Error('AVIF decode failed')
return result
}@jsquash codecs ship with their own loader logic that expects WASM files to
be served as static assets, not bundled. With Next.js the cleanest setup is to
copy the .wasm files from node_modules/@jsquash/*/codec/*.wasm into
public/wasm/ and let the codecs fetch them at runtime. Don't fight the
loader.
libwebp exposes method, pass, sns_strength, use_sharp_yuv, etc. I
spent half a day tuning these on a corpus of WebP files exported from common
tools. Net change: <1% in compression. If you're re-encoding output from
another tool that already used libwebp, you've largely hit the wall. The
real wins on WebP come from re-encoding from a different source format
(PNG → WebP gives the dramatic numbers people quote).
Treat PNG as two engines, not one with a quality knob:
quality < 100→ run throughimagequantfirst →@jsquash/pngencodequality === 100→ skipimagequant, run@jsquash/pngdecode → OxiPNG re-encode
If you funnel q100 through imagequant you get a "lossless" path that's
actually destructive.
Don't try to load SVG into a Canvas and re-encode (you'll lose all the vector benefits). Just run the SVG string through SVGO v4's browser bundle:
import { optimize } from 'svgo/browser'
const { data } = optimize(svgString, { multipass: true })42% reduction with no quality loss, faster than any of the raster pipelines.
The temptation: decode GIF → manipulate frames as ImageData → re-encode.
Don't. Use gifsicle-wasm-browser and pass the GIF File→File. The CLI
flag --lossy is what does most of the work; -O3 adds another 5-10%. If
you decode/re-encode you'll either lose frames or balloon the file.
| Package | Version | Loader |
|---|---|---|
@jsquash/jpeg |
latest | static WASM in public/wasm |
@jsquash/png + imagequant WASM |
latest | static WASM in public/wasm |
@jsquash/webp |
latest | static WASM in public/wasm |
@jsquash/avif |
latest | static WASM in public/wasm |
gifsicle-wasm-browser |
1.92 | bundled |
svgo (browser entry) |
v4 | bundled |
All run client-side. No server, no upload.
- Corpus: 100 mixed images — DSLR photos (sRGB JPEG, 4-12 MP), app/website screenshots (PNG with text & UI), social media exports (WebP/AVIF), short animated GIFs (≤ 100 frames). Mix of natural and synthetic content; no synthetic test cards (those make codecs look better than they perform on real input).
- Quality: 75 unless noted. Most production tools default in the 70–80 range — that's where the real-world tradeoff lives.
- Environment: Chrome 130 desktop, M-series Mac, no other tabs. WASM modules cached after first run. Numbers reported are after warm-up.
- Per-image variance: ±15% is normal. Median is reported.
- Comparison fairness: each input was decoded once, then encoded by every codec applicable to its format. No sample bias from encoder-specific test sets.
results.json contains the raw per-format numbers in case you want to
chart them or compare with your own corpus.
I'll be adding a runnable Node-side benchmark script next, but the numbers above are pulled from the production telemetry of Convertilo which has been running on real user uploads (anonymously, on-device) — so you're getting medians from tens of thousands of files, not just my 100-image dev corpus.
MIT
These codecs power the image-handling pipeline on Convertilo — open in your browser to try compression yourself; the same WASM modules described here run in the page. Source for the integration glue (codec wrappers, normalize/decode/encode pipeline) lives in the Convertilo repo.
Numbers wrong on your corpus? PR with your test environment + reproducible results, happy to merge with attribution. The only ask: real corpus (no synthetic test cards), and report medians not best-case.