DEV Community

TinyToolsHub
TinyToolsHub

Posted on

Compress images to an exact KB size offline with React + wasm‑mozjpeg (live demo)

TL;DR — Drop any JPG/PNG/WebP into the TinyToolsHub Image Compressor, set a target like 20 KB, and download the result. 100 % client‑side, works offline after the first load.
In this post I’ll show the React + WebAssembly trickery behind it and give you a minimal code snippet to roll your own.


Why another image compressor?

If you’ve ever tried to attach a passport‑sized photo to a government form, you know the drill: “File must be < 200 KB, 200 × 200 px.” Online tools exist, but most upload your image to a server, which:

  1. Adds latency (🥱), especially on mobile data.
  2. Leaks private metadata to a third‑party you don’t control.

WebAssembly lets us run the same C libraries that power desktop tools directly in the browser. Combine that with a tiny React UI and you get instant, private, offline compression.


The stack

Layer Choice Why
UI React 18 + Tailwind CSS Quick JSX prototyping with decent a11y
Compression engine wasm-mozjpeg Battle‑tested JPEG encoder, 100 kB gzipped
Wrapper logic TypeScript + createImageBitmap Zero Canvas flicker, promise‑based
Hosting Static next export → Cloudflare Pages No server bill, edge‑cached

Key trick #1 — hit an exact KB target (binary search)

MozJPEG’s quality parameter is non‑linear. To land on, say, 20 KB ± 0.5 KB, I wrap the encoder in a binary‑search loop:

async function compressToTarget(
  file: File,
  targetKB: number,
  maxIterations = 6,
) {
  const img = await createImageBitmap(file);
  let qMin = 0, qMax = 95, bestBlob: Blob | null = null;

  for (let i = 0; i < maxIterations; i++) {
    const q = Math.round((qMin + qMax) / 2);
    const blob = await encodeJPEG(img, q); // wasm‑mozjpeg wrapper
    const sizeKB = blob.size / 1024;

    if (sizeKB > targetKB) qMax = q - 1;
    else {
      bestBlob = blob;
      if (targetKB - sizeKB < 0.5) break; // close enough
      qMin = q + 1;
    }
  }
  return bestBlob ?? file;
}
Enter fullscreen mode Exit fullscreen mode

6 iterations are enough to converge for typical 2–4 MP photos, keeping INP well below 200 ms.


Key trick #2 — stay offline

  • wasm-mozjpeg is lazy‑loaded only after the user drops a file.
  • Once cached, the Service Worker (optional) serves everything from /~sw. Users on flaky mobile networks can still compress files while offline.

Minimal React component

import { useState } from 'react';

export default function OfflineCompressor() {
  const [outputURL, setOutputURL] = useState<string | null>(null);

  const onDrop = async (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const file = e.dataTransfer.files[0];
    if (!file) return;

    const compressed = await compressToTarget(file, 20); // → 20 KB
    setOutputURL(URL.createObjectURL(compressed));
  };

  return (
    <div
      onDragOver={e => e.preventDefault()}
      onDrop={onDrop}
      className="border-2 border-dashed rounded-lg p-8 text-center"
    >
      {outputURL ? (
        <a href={outputURL} download="compressed.jpg" className="text-blue-600 underline">
          Download 20 KB image
        </a>
      ) : (
        'Drag & drop an image here'
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

(Full source → link at the bottom.)


Lighthouse numbers

Metric Desktop Mobile
LCP 1.2 s 1.8 s
INP (Interaction) 48 ms 128 ms

All in the green ✅, even after AdSense loads (ads are gated behind userHasInteracted).


Try it yourself

👉 Live demo: tinytoolshub.com/image-compressor
Runs entirely client‑side—open DevTools → Network → disable 👁️


What’s next?

  • EXIF metadata remover (shipped 💥)
  • PDF redactor (in progress)
  • SVG → PNG converter

Appendix — colour tokens & a11y contrast ✅

Name Hex WCAG AA on light WCAG AA on dark
Primary #0087FF
Accent #FFB703
Success #22C55E

Happy compressing! 🎉

Top comments (1)

Collapse
 
flatingofamily profile image
Andrii Novichkov

That’s super slick! Love seeing practical WASM use like this - especially for offline image compression where control over exact file size matters (hello game dev builds 👀). Bookmarked the demo - curious, how’s performance compared to other tools like sharp or squoosh?