How I Built 100 Browser-Based Image Tools With No Server (FFmpeg WASM, PDF-lib, AI Background Removal) A developer built ImgToolkit, a collection of 100 browser-based image and video tools that process all files locally without uploading to any server. The project uses FFmpeg WASM for video processing, pdf-lib for PDF manipulation, and AI-powered background removal via ONNX models, all running entirely client-side through WebAssembly and the Canvas API. The developer overcame challenges including SharedArrayBuffer cross-origin isolation requirements and dynamic loading of heavy libraries to keep initial page load under 100KB of JavaScript. 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 https://dev.to/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 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. import { removeBackground } from " @imgly https://dev.to/imgly /background-removal"; const blob = await removeBackground imageFile, { publicPath: " https://cdn.imgly.com/background-removal/.. 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. 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. 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. 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 https://imgtoolkit.com/ — 100 tools, all free, all client-side. Happy to answer questions about any part of the implementation.