DEV Community

toolzip
toolzip

Posted on

How I Built a Video Compressor That Runs Entirely in Your Browser

I have a confession: I built a video compression tool and it has zero server costs.

Not because I'm cheap (well, maybe a little), but because the entire processing happens in your browser. No uploads. No waiting for a server to process your file. No wondering who's looking at your private videos.

Here's how I did it with FFmpeg.wasm.

The Problem

I wanted to build a suite of file utility tools — image compression, PDF editing, video conversion. The image and PDF parts were straightforward using Canvas API and pdf-lib. But video? Video processing is supposed to need a server.

Or so I thought.

Enter FFmpeg.wasm

FFmpeg is the industry-standard tool for video processing. It's written in C, runs on servers, and powers YouTube, Netflix, and VLC.

But someone compiled it to WebAssembly. That means it runs in a browser.

npm install @ffmpeg/ffmpeg @ffmpeg/util
Enter fullscreen mode Exit fullscreen mode

That's it. You now have FFmpeg in your browser.

The First Wall: SharedArrayBuffer

The moment I tried to run it, I got:

SharedArrayBuffer is not defined
Enter fullscreen mode Exit fullscreen mode

FFmpeg.wasm uses multithreading, which requires SharedArrayBuffer. This API is disabled by default for security reasons (Spectre attacks). To enable it, you need specific HTTP headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Enter fullscreen mode Exit fullscreen mode

In Next.js, add this to next.config.js:

const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
        ],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Works locally. Deploy to Vercel and... still broken. Vercel needs the same headers in vercel.json:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The Virtual Filesystem

FFmpeg reads and writes files. Browsers don't have a filesystem. FFmpeg.wasm provides an in-memory virtual filesystem (Emscripten FS).

const ffmpeg = new FFmpeg();
await ffmpeg.load();

const { fetchFile } = await import("@ffmpeg/util");

// Write to virtual filesystem
await ffmpeg.writeFile("input.mp4", await fetchFile(file));

// Run FFmpeg command
await ffmpeg.exec(["-i", "input.mp4", "-crf", "28", "output.mp4"]);

// Read result
const data = await ffmpeg.readFile("output.mp4");

// Convert to downloadable blob
const blob = new Blob([data.buffer], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
Enter fullscreen mode Exit fullscreen mode

Showing Real Progress

FFmpeg.wasm doesn't give you a clean progress percentage out of the box for container conversions. But it does log messages — and those logs contain timing information.

ffmpeg.on("log", ({ message }) => {
  // Parse total duration
  const durMatch = message.match(/Duration:\s*(\d+:\d+:\d+\.?\d*)/);
  if (durMatch) {
    totalDuration = timeToSeconds(durMatch[1]);
  }

  // Parse current position
  const timeMatch = message.match(/time=(\d+:\d+:\d+\.?\d*)/);
  if (timeMatch && totalDuration > 0) {
    const current = timeToSeconds(timeMatch[1]);
    const progress = Math.min(Math.round((current / totalDuration) * 100), 99);
    setProgress(progress);
  }
});

function timeToSeconds(timeStr) {
  const parts = timeStr.split(":").map(parseFloat);
  return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
Enter fullscreen mode Exit fullscreen mode

This gives you a real progress bar based on actual processing position.

The 30MB Elephant in the Room

FFmpeg.wasm core is ~30MB. Users need to download it before processing starts.

I handle this with a clear loading state:

const [ffmpegLoaded, setFfmpegLoaded] = useState(false);

const loadFFmpeg = async () => {
  const ffmpeg = new FFmpeg();
  await ffmpeg.load({
    coreURL: "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js",
  });
  setFfmpegLoaded(true);
  return ffmpeg;
};
Enter fullscreen mode Exit fullscreen mode

The good news: browsers cache it. Second visit = instant load.

What You Can Do With It

Once you have FFmpeg in the browser, the possibilities are surprising:

// Compress video
["-i", "input.mp4", "-crf", "28", "-preset", "fast", "output.mp4"]

// Convert format
["-i", "input.mov", "output.mp4"]

// Extract audio
["-i", "input.mp4", "-vn", "-acodec", "copy", "output.mp3"]

// Convert to GIF
["-i", "input.mp4", "-vf", "fps=10,scale=480:-1", "output.gif"]

// Trim
["-i", "input.mp4", "-ss", "00:00:10", "-to", "00:00:30", "-c", "copy", "output.mp4"]
Enter fullscreen mode Exit fullscreen mode

Performance Reality Check

Browser processing is slower than server processing. On my M2 MacBook, compressing a 100MB MP4 takes about 2-3 minutes. On a mid-range Android phone, closer to 8-10 minutes.

That's the trade-off. You get:

  • ✅ Zero server costs
  • ✅ Complete privacy (files never leave the device)
  • ✅ Works offline after initial load
  • ❌ Slower than server-side processing
  • ❌ Limited by device RAM (large files may fail)

Mobile Works Too

This was the surprise. Both iOS Safari and Android Chrome support WebAssembly. You can compress video on a phone without uploading it anywhere.

The Result

I shipped this as part of ToolZip — a browser-based utility suite. Video compression, format conversion, GIF export, audio extraction — all client-side.

The reaction I didn't expect: people specifically mention the "no upload" aspect in feedback. Privacy matters to users more than I anticipated.

Key Takeaways

  1. FFmpeg.wasm is production-ready — The API is stable and the WebAssembly performance is acceptable for most use cases.

  2. Headers are critical — The SharedArrayBuffer requirement trips up many deployments. Set them early.

  3. Parse logs for progress — Don't rely on the progress event for format conversions; parse log output instead.

  4. Cache aggressively — 30MB is a one-time cost. After that, it's instant.

  5. Set expectations — Tell users processing takes time and not to close the tab. Transparency prevents frustration.

The browser is more capable than most developers realize. Sometimes the server is optional.


ToolZip — 52 browser-based tools for file conversion, editing, and utilities. No uploads, no accounts, no cost.

Top comments (0)