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 jSquash packages 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.

Client-side image compression before upload The selected file is decoded to pixels, resized to the target dimensions, oriented correctly, and re-encoded with a Wasm codec in a worker. Location metadata is dropped. The much smaller result is uploaded, with the original as a fallback if anything fails. user picks photo 6 MB JPEG/HEIC decode createImageBitmap resize + orient to 1600 px long edge Wasm encode WebP / AVIF / MozJPEG upload ≈ 400 KB

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.

Output formats for uploaded photos MozJPEG is universally supported and fast to encode, with good compression. WebP is supported in all current browsers and about a quarter smaller. AVIF is smallest but slowest to encode and has the largest codec download. format size vs MozJPEG encode speed codec download MozJPEG q75 100% fast ≈ 150 KB WebP q75 ≈ 75% medium ≈ 300 KB AVIF q50 speed 6 ≈ 55% slow ≈ 1.1 MB

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.

Upload size for one 12-megapixel phone photo Kilobytes uploaded for the original camera JPEG and for client-side re-encodes at 1600 pixels with MozJPEG, WebP and AVIF. KB uploaded original camera JPEG 5,800 KB MozJPEG q75 380 KB WebP q75 280 KB AVIF q50 210 KB

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.

← Back to Media Processing & Codecs in Wasm