Building a Private Image Compressor with WebAssembly
How ImagePDF.Tools uses pngquant WASM, the Canvas API, and Web Workers to compress images entirely in the browser — no server, no upload.
The engineering challenge behind a privacy-first image tool: how do you give users professional compression quality without sending a single byte to a server? The answer is WebAssembly (WASM) — compiled native binaries running at near-native speed inside the browser sandbox.
This is a technical walkthrough of how ImagePDF.Tools compresses images client-side using pngquant WASM for PNG, the Canvas API for JPEG/WebP, and a Web Worker architecture to keep the UI thread at 60fps during processing.
Three Paths, Zero Uploads
- ●JPEG and WebP: The browser's own Canvas API and
OffscreenCanvas.convertToBlob()handle encoding with a quality parameter. Zero WASM needed. - ●PNG: Canvas PNG output is lossless — it can't produce a lossy PNG. Meaningful compression requires pngquant's colour quantisation algorithm, compiled to WASM.
- ●SVG: Pure text manipulation — remove whitespace, strip unused attributes, truncate decimal coordinates. No binary codec needed.
PNG Compression with pngquant WASM
// lib/pngquant.ts
let pngquantModule: Awaited<ReturnType<typeof initPngquant>> | null = null;
async function getPngquant() {
if (!pngquantModule) {
// WASM is loaded and compiled once, then cached in memory for the session
const { default: initPngquant } = await import('@nicolo-ribaudo/pngquant-wasm');
pngquantModule = await initPngquant();
}
return pngquantModule;
}
export async function compressPng(file: File, quality: number): Promise<File> {
const pngquant = await getPngquant();
const input = new Uint8Array(await file.arrayBuffer()); // stays in browser memory
// quality=80 → [72, 80] gives a tight quality band
const output = pngquant.compress(input, {
quality: [Math.max(0, quality * 0.9), quality] as [number, number],
speed: 3, // 1 = slowest/best, 11 = fastest/worst
});
return new File([output], file.name, { type: 'image/png' });
}JPEG and WebP via OffscreenCanvas
// lib/compress.ts — runs inside a Web Worker (no DOM access needed)
export async function compressJpegOrWebp(
file: File,
quality: number,
outputFormat: 'image/jpeg' | 'image/webp',
maxDimension = 4096,
): Promise<File> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
// OffscreenCanvas works in Workers — no DOM, no main-thread involvement
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close(); // free GPU memory immediately
const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality / 100 });
const ext = outputFormat === 'image/jpeg' ? 'jpg' : 'webp';
return new File([blob], file.name.replace(/\.[^.]+$/, '.' + ext), { type: outputFormat });
}Web Worker: Keeping the UI at 60fps
Compressing a 12MP photo can take 500ms–2s. Running that on the main thread freezes the UI. Web Workers run in a separate OS thread with access to OffscreenCanvas, WASM, and ArrayBuffer transfers — but no DOM.
// workers/compress.worker.ts
self.onmessage = async ({ data: { file, options, id } }) => {
try {
const result = options.outputFormat === 'image/png'
? await compressPng(file, options.quality)
: await compressJpegOrWebp(file, options.quality, options.outputFormat);
const buf = await result.arrayBuffer();
// Transfer ownership (zero-copy) — no ArrayBuffer clone
self.postMessage({ id, buf, name: result.name, type: result.type }, [buf]);
} catch (err) {
self.postMessage({ id, error: (err as Error).message });
}
};Prefetch the WASM binary with <link rel="prefetch" href="/_next/static/chunks/pngquant_bg.wasm"> as soon as the user lands. By the time they drop an image, the 350 KB binary is already cached.
Performance Benchmarks
- ●JPEG compression (12MP, quality 80): 180–350ms on M3 MacBook, 400–900ms on mid-range Android
- ●PNG via pngquant WASM (5MP, quality 70): 800ms–2s on M3, 2–5s on Android
- ●WebP encoding (12MP, quality 80): 120–280ms (hardware-accelerated in Chrome/Safari)
- ●WASM load + compile (first visit): ~200ms; subsequent visits: <5ms from browser cache
Try the compressor — open DevTools → Network tab and confirm: zero file upload requests while your images are processed.
Ready to try it?
All tools run entirely in your browser — no uploads, no account required.
Compress Image