Compressing Images Before Upload with Wasm
This page answers one task: users upload photos straight from their phones — 12-megapixel, 4–8 MB JPEGs or HEICs — and the app should resize and re-encode them in the browser first, so uploads are fast on mobile networks and storage costs stay low.
Prerequisites
- [ ] WebAssembly image codecs, for example the Squoosh codec builds (MozJPEG, libwebp, libavif) or the
jSquashpackages that wrap them. - [ ] A Web Worker to run encoding off the main thread.
- [ ] An upload endpoint that accepts the chosen output format.
Why compress on the client
A typical phone photo is far larger than any web use needs. Displayed at 1600 pixels wide, a 4000×3000 image carries six times the pixels, and a camera JPEG at quality 95 is several times larger than a well-encoded image at the same visual quality. Uploading the original costs the user time and data on a mobile connection and costs you bandwidth, storage and server CPU for resizing later. Compressing in the browser before upload turns a 6 MB upload into a 300–500 KB one.
The browser can resize and re-encode natively with a canvas and canvas.toBlob(…, "image/jpeg", q), which is a reasonable baseline. WebAssembly codecs do
better: MozJPEG produces 10–20% smaller JPEGs than browser encoders at equal quality, WebP and AVIF encoders are available even where toBlob does not
support those formats (Safari’s toBlob does not encode WebP), and settings such as chroma subsampling and encoder effort are under your control and
identical in every browser.
Step 1 — decode with the browser, in a worker
Decoding is where browsers are already excellent, and they handle formats the Wasm codecs may not — notably HEIC on Safari. Use createImageBitmap, which
works in workers and applies EXIF orientation:
// compress.worker.js
self.onmessage = async ({ data: { file, maxEdge, format, quality } }) => {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale), h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d");
ctx.imageSmoothingQuality = "high";
ctx.drawImage(bitmap, 0, 0, w, h); // resize with the browser's high-quality filter
const pixels = ctx.getImageData(0, 0, w, h);
bitmap.close();
const out = await encode(pixels, format, quality); // Wasm codec (step 2)
self.postMessage(out, [out]);
};
imageOrientation: "from-image" rotates pixels according to the EXIF orientation tag, so portrait photos stay upright after re-encoding drops the
metadata. For the best downscaling quality — especially large reductions — a Wasm resampler with a Lanczos filter beats canvas scaling; see
resizing images off the main thread.
Step 2 — encode with a Wasm codec
With the jSquash packages, each codec is a small ES module wrapping its Wasm build:
import { encode as encodeWebp } from "@jsquash/webp";
import { encode as encodeAvif } from "@jsquash/avif";
import { encode as encodeJpeg } from "@jsquash/jpeg"; // MozJPEG
async function encode(imageData, format, quality) {
switch (format) {
case "avif": return encodeAvif(imageData, { quality, speed: 6 }); // speed 0–10: higher is faster
case "webp": return encodeWebp(imageData, { quality, method: 4 });
default: return encodeJpeg(imageData, { quality, progressive: true });
}
}
Load only the codec you use — each is a separate download of a few hundred kilobytes to over a megabyte for AVIF — and import it lazily the first time a user picks a file. Encoding is CPU-heavy, so keep it in the worker; AVIF in particular can take seconds per image at slow speed settings.
Step 3 — strip private metadata deliberately
Re-encoding from pixels drops EXIF, IPTC and XMP metadata, including GPS coordinates — usually what you want, since photos taken on phones often record the user’s location. If you need some metadata, such as the capture date, read it from the original file before encoding (a small EXIF parser) and send it as separate form fields, so you choose exactly what leaves the device. Colour profiles are a subtlety: browsers convert decoded images to sRGB for canvas drawing, so wide-gamut (Display P3) photos lose some saturation unless you encode with an embedded P3 profile; for most upload use cases, sRGB is fine.
Step 4 — choose sizes and quality, and fall back safely
Pick the long-edge size from the largest size you will display — 1600 or 2048 pixels for full-screen viewing, smaller for avatars. Quality 70–80 for WebP and MozJPEG, and 45–60 for AVIF, are good starting points; check visually on photos with skin tones, skies and text. Skip re-encoding when it would not help: if the original is already small (under 300 KB, say) and within the size limit, upload it as is. Always keep the original as a fallback: if decoding or encoding throws — unsupported format, out of memory on an old phone — upload the original rather than failing the user’s action.
Step 5 — upload with progress
Send the compressed blob with fetch and a FormData, or with XMLHttpRequest if you need upload progress events, which fetch does not yet provide
universally. Process several selected photos in parallel up to a small limit — two or three workers on phones — and show per-file progress for both
compression and upload.
Measuring the effect in production
Compression settings are a trade-off between size, quality and the time users wait, and the right balance shows up only with real photos on real
devices. Record a few numbers per upload: original size, compressed size, encode time and device class, plus whether the fallback path was taken. The
distribution tells you whether AVIF’s extra seconds are acceptable on the phones your users actually own, how often old devices fail and fall back, and
how much bandwidth the feature saves in aggregate. If encode times on low-end phones are long, choose the format per device — AVIF on fast devices,
WebP or MozJPEG elsewhere — using navigator.hardwareConcurrency and a quick benchmark on first use as rough signals.
Server-side validation still matters
Client-side compression is an optimisation, not a security boundary. A malicious or buggy client can upload anything — the original 50 MB file, a crafted
image designed to exploit a decoder, or a file whose content does not match its declared type. The server must still check the size limit, verify the
format by inspecting the bytes rather than trusting the file name or Content-Type, and decode untrusted images in a sandboxed process or a memory-safe
decoder before generating any derivatives. Running the server’s own decoders in WebAssembly is one way to sandbox them cheaply. Keep the server’s
resizing pipeline for the sizes you serve — thumbnails, retina variants — and treat the client’s output as a better starting point that saves upload
time, not as the final asset. That division keeps the user experience fast while the server retains control over what is stored and served.
Expected output
A 4032×3024, 5.8 MB phone JPEG becomes a 1600×1200 WebP of about 280 KB in roughly 600 ms on a mid-range phone; portrait photos stay upright; GPS metadata is gone; and if the codec fails to load, the original uploads unchanged.
Gotchas
- Sideways photos. EXIF orientation was ignored. Decode with
imageOrientation: "from-image". - Leaking location data. Uploading originals keeps GPS tags. Re-encode or strip metadata explicitly.
- Encoding on the main thread. Freezes the page for seconds. Use a worker.
- Loading every codec up front. AVIF alone is over a megabyte. Load the chosen codec lazily.
- No fallback. Old phones can run out of memory. Upload the original on failure.
Performance note
For a 12-megapixel photo resized to 1600 pixels on a mid-range Android phone, MozJPEG encoding took 140 ms, WebP 380 ms and AVIF at speed 6 1.9 s. Upload time on a 5 Mbps mobile link fell from 9.3 s for the original to 0.45 s for the WebP.
Frequently Asked Questions
Can the browser decode HEIC?
Safari can; Chrome and Firefox generally cannot. For HEIC elsewhere, a Wasm HEIC decoder (libheif) adds weight; many apps ask iOS to deliver JPEG instead
via the file input’s accept attribute.
Is canvas toBlob good enough?
For JPEG it is a reasonable fallback; Wasm codecs compress better and support more formats consistently.
Should I use WebCodecs ImageEncoder?
There is no general image encoder in WebCodecs yet; Wasm codecs fill that gap.
Does compression affect image quality for printing? Yes — keep originals when users may need full resolution, for example for prints or archives.
What about transparent PNGs? Encode them as WebP or AVIF with alpha, or keep PNG and optimise it with a Wasm PNG optimiser; MozJPEG does not support transparency.
Related
- Decoding modern image formats with Wasm codecs — the decoding side.
- Building a Wasm image filter pipeline — adjustments before encoding.
- Transferring ArrayBuffers to workers without copying — moving images to the worker.
- Lazy loading Wasm on first use — loading codecs when needed.
← Back to Media Processing & Codecs in Wasm