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. DecodecreateImageBitmap for standard formats, a dedicated path for HEIC
  2. Transform — resize, rotate, flip (applied before encoding, never after)
  3. Encodecanvas.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.