DEV Community

mengyuxuan
mengyuxuan

Posted on

I built a video compressor with zero backend — your files never leave the tab

Drop a 200 MB screen recording into most "free video compressor" sites and here's what actually happens: the file uploads to someone's server, gets transcoded there, and you download the result. Your video sat on a stranger's disk. The site paid for bandwidth and CPU, so it pays that back with aggressive ads or a paywall at 3 files a day.

I wanted the opposite: the compression runs in your browser tab, the file never leaves your machine, and because there's no server doing the work, the whole thing is genuinely free to run.

That's videocompress.dev. This post is how it works under the hood.

The core constraint: no upload, ever

The entire architecture falls out of one rule — the video bytes stay client-side. That immediately kills the "rent a beefy transcoding server" design and forces the work into the browser. Two APIs can do it:

  1. WebCodecs — a low-level browser API that talks to the OS's hardware video encoder/decoder. Fast, uses almost no CPU, native-quality H.264.
  2. ffmpeg.wasm — the full FFmpeg compiled to WebAssembly. Works literally everywhere, but it's a software encoder running in a WASM sandbox, so it's slower.

WebCodecs is the better experience when it's available. It isn't always. So the app probes the browser and picks.

Picking an engine at runtime

The selection logic is deliberately boring — a pure function that's trivial to unit test:

export type CompressionEngine = "webcodecs" | "ffmpeg";

export interface EngineCaps {
  hasWebCodecsApi: boolean;   // VideoEncoder + VideoDecoder exist
  inputReadable: boolean;     // we can actually demux + decode this file
  canEncodeTarget: boolean;   // the target H.264 config is encodable here
}

export function decideEngine(caps: EngineCaps): CompressionEngine {
  return caps.hasWebCodecsApi && caps.inputReadable && caps.canEncodeTarget
    ? "webcodecs"
    : "ffmpeg";
}
Enter fullscreen mode Exit fullscreen mode

The important detail is the third capability. Having the WebCodecs API isn't enough — the browser also has to be able to demux the specific container you dropped in and encode the target config. So before committing, the app asks mediabunny whether it can read the input's primary video track and whether VideoEncoder.isConfigSupported() accepts the output config.

Any false, anything unprobed, anything that throws → fall back to ffmpeg.wasm. WebCodecs is the fast path you earn by passing every check; ffmpeg is the floor that always catches you. Users on the happy path get hardware speed; everyone else still gets a working compressor. Nobody gets a broken page.

The actual "compression": it's bitrate math

"Compress this to 25 MB for email" sounds like a quality knob, but under the hood it's arithmetic. A video file's size is roughly bitrate × duration. If you know the target size and the duration, you can solve for the bitrate:

export const AUDIO_KBPS = 128;
export const TARGET_SIZE_SAFETY = 0.94; // aim slightly under so we don't overshoot

export function computeTargetVideoKbps({
  targetSizeMB,
  durationSec,
  removeAudio,
}: {
  targetSizeMB: number;
  durationSec: number;
  removeAudio: boolean;
}): number {
  const audioKbps = removeAudio ? 0 : AUDIO_KBPS;
  const safeTargetMB = targetSizeMB * TARGET_SIZE_SAFETY;
  const videoKbps = (safeTargetMB * 8192) / Math.max(durationSec, 1) - audioKbps;
  return Math.max(Math.round(videoKbps), 100);
}
Enter fullscreen mode Exit fullscreen mode

Two things I learned the hard way and baked in:

  • The 6% safety margin (0.94). Encoders don't hit a bitrate target exactly; they hover around it. If you aim for exactly 25 MB you'll ship 26 and blow the email limit. Aiming for ~23.5 lands under it reliably.
  • Subtract the audio budget first. The size cap is for the whole file. Spend 128 kbps on audio and the video has to fit in what's left — otherwise your "target size" mode quietly ignores the audio track and overshoots.

The other mode is quality-based, which maps straight to H.264's CRF (constant rate factor):

const QUALITY_CRF = { high: 20, balanced: 23, small: 28 };
Enter fullscreen mode Exit fullscreen mode

Lower CRF = better quality + bigger file. 23 is a genuinely good default for most footage; 28 is where you go when "smaller" matters more than "pristine."

Why "no server" changes the whole economics

This is the part I think other devs will appreciate. Because the transcode happens in the tab:

  • Hosting is basically free. The app is 100% static — it's just HTML, JS, and a WASM blob on a CDN. No transcoding boxes, no per-video cost, no scaling worries. A traffic spike costs bandwidth on static assets, nothing more.
  • Privacy is architectural, not a promise. Plenty of sites say "we delete your files." Here there's nothing to delete because nothing was ever sent. Open DevTools → Network tab and compress a file: zero upload requests.
  • It works offline once the page and core are cached.

The one gotcha: @ffmpeg/core-mt (the multithreaded build) needs SharedArrayBuffer, which needs cross-origin isolation (COOP/COEP headers), which breaks a lot of third-party embeds. I chose the single-thread @ffmpeg/core on purpose — slightly slower, but no header gymnastics and full compatibility with everything else on the page. Know your tradeoff before you reach for the MT build.

The stack

Nothing exotic, deliberately:

  • Next.js 16 (App Router) + React 19 + TypeScript
  • Tailwind CSS 4
  • @ffmpeg/ffmpeg 0.12 for the fallback path, mediabunny for WebCodecs demux/probing
  • Static export — every page is SSG, deployable to Vercel / Cloudflare Pages / Netlify free tiers

The compression parameter functions (the bitrate/CRF math above) are pure and covered by node --test unit tests. When the thing that matters is "did we compute the right bitrate," you want that logic testable without spinning up a browser or a real encoder.

Try it / read it

  • The tool: videocompress.dev — drop a file in, pick a target size or quality, download. No account, no upload, no watermark.
  • If you're building something similar, the WebCodecs-with-ffmpeg-fallback pattern and the bitrate math are the two pieces worth stealing.

If you end up shipping in-browser media processing, I'd genuinely like to hear how you handled the engine-selection edge cases — the "API exists but can't encode this" gap is where most of the real bugs live. Drop a comment.

Top comments (0)