DEV Community

Shahrukh Khan
Shahrukh Khan

Posted on

How I built 272 privacy-first tools with WebAssembly and no backend

Five weeks ago I started building a PDF compressor. Today the site has 272 tools and none of them upload your files to a server. This is what the architecture looks like, why I made those choices, and where it breaks.

The premise

Every "free online PDF compressor" I could find has the same shape: you upload your file, their server compresses it, you download it back. Their privacy policy says something like "files are deleted within 24 hours" and you either trust that or you don't.

You don't have to trust anything if the file never leaves your machine.

Browsers in 2026 can do this. Between WebAssembly, Web Workers, the Canvas API, and a handful of mature JS libraries, you can compress PDFs, convert HEIC to JPG, turn a MOV into an MP4, and run every developer utility you'd want, entirely client-side. So I did that, 272 times.

The result is toolspace.cloud — 272 browser tools across PDF, image, video, audio, converters, calculators, writing, and developer utilities. No signup, no upload, no ads.

The stack

  • Next.js 16 — static export for tool pages. Each tool is essentially an HTML file plus a JS bundle that lazy-loads whatever wasm library it needs.
  • ffmpeg.wasm — video and audio work.
  • pdf-lib — PDF construction (merge, split, watermark, page manipulation).
  • pdf.js — PDF rendering (for previews and PDF-to-image).
  • libheif-js — HEIC decoding (Safari has native support, other browsers don't).
  • Canvas API + OffscreenCanvas — image resize, convert, crop, filter.
  • Web Workers — every heavy operation runs off the main thread.
  • Service worker — caches wasm binaries after first load, so the second visit to any video tool is instant.

Nothing exotic. Everything on that list has been production-ready for at least a year.

Example 1: HEIC → JPG with ffmpeg.wasm

HEIC is the format iPhones save photos in. Every non-Apple platform hates it. There are two ways to convert it in the browser: libheif-js (fast, small) or ffmpeg.wasm (heavier but handles a wider format matrix and you probably already have it loaded for video tools).

Here's the ffmpeg.wasm version, which is what Toolspace uses when a user opens the "batch image converter" that supports HEIC alongside 20 other formats:

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

let ffmpegInstance = null;

async function getFFmpeg() {
  if (ffmpegInstance) return ffmpegInstance;

  const ffmpeg = new FFmpeg();
  const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm';

  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
    workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, 'text/javascript'),
  });

  ffmpegInstance = ffmpeg;
  return ffmpeg;
}

export async function heicToJpg(file, quality = 90) {
  const ffmpeg = await getFFmpeg();

  const inputName = 'input.heic';
  const outputName = 'output.jpg';

  await ffmpeg.writeFile(inputName, await fetchFile(file));

  await ffmpeg.exec([
    '-i', inputName,
    '-q:v', String(Math.round(31 - (quality * 0.31))), // ffmpeg's quality scale is inverted
    outputName,
  ]);

  const data = await ffmpeg.readFile(outputName);

  // Clean up the virtual FS so we don't leak memory across conversions
  await ffmpeg.deleteFile(inputName);
  await ffmpeg.deleteFile(outputName);

  return new Blob([data], { type: 'image/jpeg' });
}
Enter fullscreen mode Exit fullscreen mode

Two things worth flagging:

  1. ffmpegInstance is memoized. ffmpeg.wasm's load() is expensive (multi-MB wasm binary). Loading once per session and reusing across conversions is the difference between "usable" and "unusable."
  2. You must delete files from the virtual FS. ffmpeg.wasm keeps an in-memory MEMFS. If you don't clean up, batch conversions leak memory until the tab dies.

Cross-origin isolation matters here too. To use multi-threaded ffmpeg (which is 3–5× faster), the page needs Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. Without them, SharedArrayBuffer isn't available and you fall back to single-threaded.

Example 2: PDF merge with pdf-lib

PDF merge is the tool people search for most, and it's the most trivial to implement — which tells you something about how much of the "PDF SaaS" industry is glue code with a marketing budget.

import { PDFDocument } from 'pdf-lib';

export async function mergePdfs(files) {
  // `files` is an array of File objects from an <input type="file" multiple>

  const mergedPdf = await PDFDocument.create();

  for (const file of files) {
    const arrayBuffer = await file.arrayBuffer();
    const pdf = await PDFDocument.load(arrayBuffer, {
      ignoreEncryption: false, // fail loudly on password-protected PDFs
    });

    const copiedPages = await mergedPdf.copyPages(
      pdf,
      pdf.getPageIndices()
    );

    copiedPages.forEach((page) => mergedPdf.addPage(page));
  }

  const mergedBytes = await mergedPdf.save({
    useObjectStreams: true, // smaller output
  });

  return new Blob([mergedBytes], { type: 'application/pdf' });
}
Enter fullscreen mode Exit fullscreen mode

That's the whole tool. Add a drag-and-drop UI on top and you have something SmallPDF would charge you a subscription for. The reason they can charge for it is that most users don't know pdf-lib exists.

Password-protected PDFs are the one gotcha. ignoreEncryption: false means you get an error you can catch and surface to the user ("this PDF is password-protected, unlock it first"). Setting it to true will silently produce broken output.

Where it breaks

Being honest about limits is the whole point of the project, so:

1. File size is bounded by browser RAM.
Chrome on a decent desktop handles ~500MB. Safari on iOS caps way lower. If you try to merge a 2GB PDF, the tab dies. Server-side tools don't have this ceiling.

2. First-load latency is real.
The multi-threaded ffmpeg.wasm build is ~30MB gzipped. First visit to a video tool takes 3–8 seconds to fetch and instantiate. Subsequent visits are instant (service worker), but that first hit is a real cost that server-side competitors don't pay.

3. No SharedArrayBuffer in embedded webviews.
Some in-app browsers (Facebook, Instagram, older WebViews) don't allow SharedArrayBuffer even with correct headers. Those users get the single-threaded ffmpeg build automatically. It works, it's just slower.

4. Battery.
Compressing a big video in a phone browser will heat the device and burn battery. A server would do it once and cache. There is no free lunch.

Why I still did it this way

Because the alternative is asking users to trust that a random "free PDF compressor" is actually deleting their files. And they shouldn't have to trust that. If the file physically never leaves the device, there is nothing to trust — there's just what the code does, which is verifiable in DevTools.

The trade-off is: I can't offer the biggest possible file sizes, and the first load is heavier. In exchange, the user's file is theirs.

Five weeks in, that trade-off feels right. Feedback welcome — especially from anyone who's built with ffmpeg.wasm at scale, because the memory-management edge cases are still where I lose the most sleep.

Top comments (1)

Collapse
 
respect17 profile image
Kudzai Murimi •

The honesty about where it breaks (RAM ceiling, first-load cost, no SharedArrayBuffer in embedded webviews) is what makes this credible. 272 tools in five weeks is a wild pace too.