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:
- Adds latency (🥱), especially on mobile data.
- 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;
}
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>
);
}
(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)
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?