When I started building ImgToolkit, the goal was simple: every image tool site I used either uploaded my files to some server I didn't trust, watermarked the output, or locked the useful features behind a $12/month plan.
I wanted to build something where everything runs in the browser. Your files never leave your device. No server, no account, no paywall.
This is the technical breakdown of how I got 100 tools working entirely client-side.
The core idea: the browser is powerful enough
Modern browsers have the Canvas API, WebAssembly, Web Workers, and file system access. With the right libraries, you can do things that felt server-only three years ago.
Here's the stack I settled on:
React + Vite — fast builds, lazy-loaded routes so each tool only loads what it needs
Canvas API — handles 80% of image operations (resize, crop, rotate, watermark, convert formats)
pdf-lib — pure JS PDF manipulation (merge, split, compress, add pages)
pdfjs-dist — PDF rendering to canvas (for PDF to JPG conversion)
FFmpeg WASM — video processing in the browser
@imgly/background-removal — AI background removal using ONNX models
Tesseract.js — OCR, runs a full Tesseract engine via WASM
browser-image-compression — handles the heavy lifting for image compression
The interesting challenges
- FFmpeg in the browser FFmpeg WASM was the one I was most skeptical about. A 30MB WASM binary that runs a full media processing pipeline in a browser tab?
It works. But there are gotchas:
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
const ffmpeg = new FFmpeg();
await ffmpeg.load({
coreURL: await toBlobURL(/ffmpeg-core.js, "text/javascript"),
wasmURL: await toBlobURL(/ffmpeg-core.wasm, "application/wasm"),
});
await ffmpeg.writeFile("input.mp4", await fetchFile(file));
await ffmpeg.exec(["-i", "input.mp4", "-q:a", "0", "-map", "a", "output.mp3"]);
const data = await ffmpeg.readFile("output.mp3");
The WASM binary needs SharedArrayBuffer, which requires cross-origin isolation headers (COOP + COEP). Getting those headers right in production took longer than writing the actual tool.
Dynamic import on the FFmpeg tools was essential — you don't want 30MB loading on the homepage.
- AI background removal with zero server calls @imgly/background-removal uses an ONNX model served from a CDN. First load downloads ~30MB of model weights. After that, it's cached by the browser.
import { removeBackground } from "@imgly/background-removal";
const blob = await removeBackground(imageFile, {
publicPath: "https://cdn.imgly.com/background-removal/...",
model: "medium",
});
The result is a PNG with a transparent background, generated entirely in the user's browser using WebGL/WASM inference. No API key, no per-request cost, no server. The quality is genuinely good — comparable to early Remove.bg results.
The tricky part: onnxruntime-web must be installed as a direct dependency alongside the library, not just a peer dependency. Took me an embarrassingly long time to debug that.
- Client-side PDF manipulation pdf-lib is underrated. You can merge, split, compress, add signatures, rotate pages, and add form fields — all in the browser.
import { PDFDocument } from "pdf-lib";
const mergedPdf = await PDFDocument.create();
for (const file of files) {
const bytes = await file.arrayBuffer();
const pdf = await PDFDocument.load(bytes);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach(p => mergedPdf.addPage(p));
}
const merged = await mergedPdf.save();
For "compress PDF", I re-encode all images inside the PDF at lower quality. Not perfect, but gets 30–60% size reduction on scanned documents without any server.
- Lazy loading everything With 100 tools, the initial bundle would be enormous if not handled carefully. Every tool page is a lazy-loaded route:
const RemoveBackground = lazy(() => import("@/pages/remove-background"));
const FfmpegVideoToMp3 = lazy(() => import("@/pages/video-to-mp3"));
Heavy libraries (FFmpeg, face-api, background-removal) are dynamically imported inside the page component, not at route level — so they only load when the user actually uses that tool.
Initial page load is under 100KB of JS. A user who only compresses images never downloads any FFmpeg or ONNX code.
- Face blurring without a server @vladmandic/face-api loads face detection models from jsDelivr CDN on first use. The models (~6MB) detect face bounding boxes in the browser. I then apply a CSS blur filter to the canvas region:
ctx.filter = blur(${blurStrength}px);
ctx.drawImage(canvas, x, y, w, h, x, y, w, h);
ctx.filter = "none";
Works surprisingly well on photos with 1–4 faces. Degrades on crowds — but so does every commercial API at that task.
What I learned
The browser is ready. WebAssembly, ONNX inference, full PDF manipulation, video processing — it all works. The main limits are file size (very large files hit memory limits) and first-load time for WASM binaries.
Lazy loading is non-negotiable. Without it, you're shipping 50MB of JS to every visitor regardless of which tool they use.
Headers matter for WASM. SharedArrayBuffer requires COOP: same-origin and COEP: require-corp. Get these wrong and FFmpeg silently fails.
Client-side means private by default. Users immediately trust a tool more when you can prove their files never leave their device. It's a genuine differentiator, not just a marketing claim.
The site is at imgtoolkit.com — 100 tools, all free, all client-side. Happy to answer questions about any part of the implementation.





















