DEV Community

吴美良
吴美良

Posted on

"How I Built a Browser Image Compressor That Handles 30 Images at Once"

Most image compressors do the actual work on a server. Mine runs entirely in the browser — 30 images at a time, re-encoded with the Canvas API, on your device. Here's the architecture that makes that possible, and the bugs that nearly broke it.

I built CompressFast to scratch my own itch, and the constraint was non-negotiable: files must never leave the device. That single decision shaped everything downstream.

The architecture: keep the heavy work off the main thread

Re-encoding a 4000×4000 photo is CPU-heavy. If you do it on the main thread, the UI freezes and the browser flags "page unresponsive". So the core is a Web Worker that owns the entire compression pipeline:

Main thread (React)  ←→  Web Worker
   upload / UI              └─ decode → transform → encode → transfer
Enter fullscreen mode Exit fullscreen mode

The main thread handles selection, previews, and state. The worker does:

  1. Decode — createImageBitmap for standard formats, a dedicated path for HEIC
  2. Transform — resize, rotate, flip (applied before encoding, never after)
  3. Encode — canvas.toBlob('image/webp', quality) (and JPEG/PNG/AVIF)
  4. Transfer — the compressed Blob is handed back via postMessage

The key detail: use transferables (ArrayBuffer, not structured-clone of a Blob) wherever you can. For a 30-image batch, that's the difference between instant and a half-second of GC pressure per file.

Auto format detection — the "smart" part

Not every image should be compressed the same way. Photos want lossy WebP; screenshots and logos want lossless PNG. So the worker classifies each file and picks a strategy:

  • JPEG/photo → WebP, quality 75–82% (the visual-equivalence sweet spot)
  • PNG/screenshot → lossless (oxipng-style), 20–60% smaller with zero pixel change
  • GIF → animated WebP (60–80% smaller)
  • HEIC (iPhone) → decoded first, then treated as a photo

This is why batch mode doesn't need per-file settings — the user drops a folder, and each image gets the right treatment automatically.

Three bugs that took way too long to find

If you're building anything with Web Workers, learn from my scars:

1. A worker can't reference window — even transitively.
I pulled in a library whose module top-level touched window. In the worker context, Webpack's chunk threw ReferenceError: window is not defined, and every compression hung on the spinner with no error in the console. Fix: audit every worker import — grep -r "window\|document" node_modules/<lib> — and keep browser-only libraries on the main thread, passing data to the worker afterward.

2. Integer truncation broke image scaling.
My fast resize path used Math.round(1/ratio) as a pixel step. For large images that truncation pushed the source coordinate past the row width, and the image came out split horizontally with black mosaic at the bottom. Fix: exact floating-point ratios plus a Math.min() bounds guard on every manual pixel loop.

3. An async handler corrupted processing order.
I made the worker's message handler await a transform after encoding. Async handlers don't preserve order — the rotation state got out of sync with the blob. Fix: keep worker message handlers synchronous. Apply transforms at download time, not inside the handler.

Bonus: Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy headers will silently block worker scripts in production (they worked fine locally). Test any security header change against a real deployment.

Scaling up to 30 files without melting the tab

Batch mode adds two more concerns:

  • Throttle re-renders. Resize/compare previews fire on every pointer move. A requestAnimationFrame throttle keeps it at 60fps instead of 300 wasted renders per second.
  • Memory. Blobs stay in memory until download. Packing the final results into a single ZIP (client-side, jszip) keeps 30 "Download" buttons from becoming 30 separate saves.

The result

A 1920×1080 screenshot goes 2.1 MB → 186 KB WebP (91%) → 102 KB AVIF (95%), all computed on-device with no server round-trip. Batch a whole folder, get one ZIP back.

The whole stack — Next.js, a Web Worker, Canvas, and a ZIP library — costs $0/month to run, because there's no compression server to pay for. That's the real payoff of going browser-side: your users' machines do the work you'd otherwise pay for, and their files never leave their hands.

If you want to try it: compressfast.site — free, 30 files per batch, no account.

What's the worst Web Worker bug you've hit? I'd love to add it to the list.

Top comments (1)

Collapse
 
amitfeldman profile image
Amit Feldman •

Handling 30 images at once fully client-side is the hard part of this category — most browser compressors fall over on the batch queue long before the compression itself.

I ran a quick launch check on compressfast.site, and it's genuinely near-clean: HSTS with a two-year max-age, X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy all set, TLS 1.3, tidy SEO basics — title, single h1, robots.txt and sitemap all live. You're one header away from a full sweep: Content-Security-Policy is the only one missing.

That one matters more here than on most launches. The h1 promises "Privacy Safe" — CSP is what makes that claim structural rather than aspirational. default-src 'self' means that even a compromised dependency or an injected script can't exfiltrate the images a user drops in. And since the app already loads no third-party assets by design, a strict self-only policy should be close to free to adopt — no allowlist whack-a-mole.

One smaller thing from the same pass: the meta description is 174 chars, so Google truncates it mid-sentence right around "PNG, JPEG," — trimming to ~155 chars keeps the format list visible in the search snippet, which is arguably your strongest line.

Happy to re-scan once CSP lands and confirm the full set is green.