Back to Blog
Technicalffmpegwebassemblybrowser

How FFmpeg Runs Entirely in Your Browser

A technical explainer of how loopaloo runs FFmpeg compiled to WebAssembly entirely in the browser: how the single and multi threaded cores load, why cross-origin isolation is required, how the in-memory virtual filesystem works, and the real memory tradeoffs versus native or server-side encoding.

LoopalooPublished April 3, 20269 min read

FFmpeg is a C program. It has been the workhorse of video encoding for two decades, and historically the only way to use it was a command line on a server or your own machine. So it is a little surprising the first time you compress a video on a website and watch your network tab stay quiet: no upload, no download of a result file from a server, just your CPU fans spinning up. The whole encoder is running inside the browser tab.

That works because FFmpeg has been compiled to WebAssembly. The @ffmpeg/ffmpeg package (version ^0.12.15 here) wraps a WASM build of the real FFmpeg codebase and gives you a JavaScript API that mirrors the command-line tool you already know. You write files into a virtual filesystem, hand FFmpeg an argument array (-i input.mov ... output.mp4), and read the result back out. No bytes leave the page.

This post walks through how that actually works in our codebase: how the WASM core gets loaded, why it needs special HTTP headers to run multithreaded, how the in-memory filesystem works, and where the real tradeoffs are compared to native or server-side encoding. The code references are all from src/lib/utils/ffmpeg.ts, next.config.ts, and the project's package.json.

The two builds: single-threaded and multi-threaded

FFmpeg-on-WASM ships in two flavors, and the difference matters a lot for performance. There is a single-threaded core (@ffmpeg/core) that runs anywhere, and a multi-threaded core (@ffmpeg/core-mt) that uses Web Workers plus a SharedArrayBuffer to spread encoding across CPU threads. The multi-threaded build is faster for CPU-bound work like H.264 encoding, but it has a hard prerequisite: the page must be cross-origin isolated.

Our loader checks for that capability before deciding which build to fetch. From loadFFmpeg():

function isSharedArrayBufferAvailable(): boolean {
  try {
    return typeof SharedArrayBuffer !== 'undefined';
  } catch {
    return false;
  }
}

const canUseMultiThread = isSharedArrayBufferAvailable();
const isCrossOriginIsolated =
  typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;

if (canUseMultiThread && isCrossOriginIsolated) {
  // load the multi-threaded core (ffmpeg-core.js + .wasm + .worker.js)
} else {
  // fall back to the single-threaded core
}

Both conditions have to hold. SharedArrayBuffer can exist as a global but still be unusable if the page is not isolated, so the code also reads crossOriginIsolated, the browser's authoritative signal. If either is missing, it quietly drops to the single-threaded build instead of failing outright. That graceful degradation is the whole reason both cores are shipped.

The multi-threaded path loads three files (ffmpeg-core.js, ffmpeg-core.wasm, and ffmpeg-core.worker.js); the single-threaded path needs only the first two. The .worker.js file is what spawns the additional threads.

Why cross-origin isolation is required

SharedArrayBuffer is memory that multiple threads (the main thread and Web Workers) can read and write at the same time. That shared, concurrent access is exactly what made the Spectre family of CPU side-channel attacks practical in browsers. The response from browser vendors was to disable SharedArrayBuffer by default and only re-enable it for pages that opt into a stricter security context called cross-origin isolation.

You opt in with two response headers:

HeaderValue we useEffect
Cross-Origin-Opener-Policysame-originSevers the link to cross-origin windows and popups that opened the page
Cross-Origin-Embedder-PolicycredentiallessRequires embedded subresources to be explicitly loadable cross-origin

When both are present, the browser sets crossOriginIsolated to true and unlocks SharedArrayBuffer. In next.config.ts these are applied to every route:

async headers() {
  return [{
    source: '/:path*',
    headers: [
      { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
      { key: 'Cross-Origin-Embedder-Policy', value: 'credentialless' },
    ],
  }];
}

Because this is a static export (output: 'export' is set in the same config), the next.config.ts headers() function does not apply to the deployed static files; it only works while Next.js is serving in dev. Production hosting carries its own copy: vercel.json declares the same two headers for /(.*), and public/_headers declares them for Netlify and Cloudflare Pages. Each of those also pins a one-year immutable Cache-Control on /ffmpeg/*, since the WASM core is large and never changes between versions.

A subtle point about the COEP value. The strict choice is require-corp, but that breaks any cross-origin resource (an image, a font, an analytics beacon) that does not send a CORP header back. We use credentialless instead, which still grants isolation but lets credential-less cross-origin requests through. The comment in public/_headers spells out the motivation: "Using 'credentialless' to allow external resources (images, fonts, APIs)." It is the pragmatic setting for a real site that loads third-party assets.

Loading the core without dynamic imports

There is a small but interesting wrinkle in how the multi-threaded core is loaded. Rather than letting the library resolve the core via a dynamic import, the code fetches the three files itself and turns them into blob URLs:

const [coreResponse, wasmResponse, workerResponse] = await Promise.all([
  fetch(`${mtBaseURL}/ffmpeg-core.js`),
  fetch(`${mtBaseURL}/ffmpeg-core.wasm`),
  fetch(`${mtBaseURL}/ffmpeg-core.worker.js`),
]);

const coreURL = URL.createObjectURL(
  new Blob([await coreBlob.text()], { type: 'text/javascript' }));
const wasmURL = URL.createObjectURL(
  new Blob([await wasmBlob.arrayBuffer()], { type: 'application/wasm' }));
const workerURL = URL.createObjectURL(
  new Blob([await workerBlob.text()], { type: 'text/javascript' }));

await ffmpeg.load({ coreURL, wasmURL, workerURL });

The comment in the source flags the reason: "Fetch files and create blob URLs manually to avoid dynamic import issues." The files are served locally from public/ffmpeg/mt, with @ffmpeg/util's toBlobURL helper used on the fallback paths. The whole sequence is wrapped in layered fallbacks: local multi-threaded core first, then a multi-threaded CDN copy (unpkg, @ffmpeg/core-mt@0.12.6 umd), then the local single-threaded core, then a single-threaded CDN copy (@ffmpeg/core@0.12.6 umd). If the fast path is blocked for any reason, encoding still works, just slower.

The instance itself is a module-level singleton. loadFFmpeg() reuses an already-loaded FFmpeg object and de-duplicates concurrent load calls through a shared loadPromise, so opening three video tools in a row downloads the multi-megabyte core exactly once.

The virtual filesystem: writeFile and readFile

FFmpeg the C program reads from and writes to a filesystem. The WASM build keeps that assumption, so the library gives you an in-memory filesystem (Emscripten's MEMFS) that lives entirely in the WASM heap. You stage inputs with writeFile, run the command, then pull results with readFile. Nothing touches your real disk.

The audio format converter is the cleanest example of the full cycle:

const inputName = 'input' + getFileExtension(inputFile.name);
const outputName = `output.${outputFormat}`;

// Stage the input into the virtual FS
await ffmpeg.writeFile(inputName, await fetchFile(inputFile));

// Run the command
await ffmpeg.exec(['-i', inputName, outputName]);

// Pull the result back out
const data = await ffmpeg.readFile(outputName) as Uint8Array;

// Free the heap memory
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);

return new Blob([data.buffer as ArrayBuffer], { type: getMimeType(outputFormat) });

fetchFile (from @ffmpeg/util) turns a browser File, Blob, or URL into the Uint8Array that writeFile expects. readFile returns a Uint8Array whose backing buffer we wrap in a Blob so the browser can offer it as a download or feed it to a <video> element.

The virtual filesystem also lets you stage files FFmpeg expects to find by name, not just media. Two patterns in the codebase rely on that:

  • Concat lists. To merge files, the merge functions write a plain-text manifest into the FS and point FFmpeg at it with the concat demuxer. The text is just FFmpeg's file '...' syntax, written via writeFile('concat.txt', new TextEncoder().encode(concatList)), then read with -f concat -safe 0 -i concat.txt.
  • Fonts. Text watermarks need a real font file on disk for the drawtext filter. The watermark function fetches /fonts/roboto.ttf over HTTP, writes it into the virtual FS as font.ttf, and references it as fontfile=font.ttf.

Because every byte lives in the WASM heap, cleanup is not optional housekeeping; it is memory management. Each operation calls deleteFile on its inputs and outputs, and there is a cleanupFFmpeg() helper that lists the root directory and deletes everything left behind. Forgetting to delete a large input means it sits in the heap until the next operation, which on a memory-bound encode can be the difference between success and an out-of-memory abort.

Running exec: the same arguments you would type

The exec call takes the exact argument array you would pass on a real FFmpeg command line, which is what makes existing FFmpeg knowledge transfer directly. The video compressor builds its argument list conditionally and hands it over:

const args = ['-i', inputName];
if (scale) {
  args.push('-vf', `scale=${scale}`);
}
args.push(
  '-c:v', 'libx264',
  '-crf', crf.toString(),
  '-preset', 'ultrafast',
  '-tune', 'fastdecode',
  '-threads', '0',
  '-c:a', 'aac',
  '-b:a', '128k',
  '-movflags', '+faststart',
  outputName
);
await ffmpeg.exec(args);

A few choices in there are specifically tuned for the browser. -preset ultrafast trades compression efficiency for speed, which matters far more when the encoder is competing for a single tab's CPU budget than it does on a build server. -threads 0 tells FFmpeg to use all available threads, which only pays off on the multi-threaded core. -movflags +faststart moves the MP4 metadata to the front so the result starts playing before it is fully downloaded.

You can observe progress and logs through events. Most functions attach ffmpeg.on('progress', ...) to drive a progress bar; the reverse-video function attaches ffmpeg.on('log', ...) and scrapes the Duration: line out of FFmpeg's stderr to learn how long the clip is, since there is no separate ffprobe binary in this build. exec resolves with FFmpeg's exit code, and the GIF converter checks if (result !== 0) to detect failure.

One operational detail worth calling out: the progress numbers FFmpeg emits are not always linear or even monotonic. The codebase wraps them in a createSmoothProgress helper that smooths the raw values and only reports when they move at least half a percent, which keeps the UI from jumping around. That is cosmetic, not a measurement of real throughput.

The tradeoff: no upload, bounded by browser memory

The reason to do any of this is privacy and friction. Your video never leaves the device, there is no upload wait on a slow connection, and there is no server bill that scales with how many people encode 4K footage. For a free tool suite that is a genuinely good deal: the user's own CPU does the work.

The cost is just as real, and it is mostly about memory. A WebAssembly heap has a ceiling. The classic 32-bit WASM build addresses memory with 32-bit pointers, which caps the heap in the low gigabytes, and the browser tab has its own limits on top of that. FFmpeg often needs several times the size of a clip in working memory, especially for operations that buffer many frames. The reverse filter is the textbook case: it has to hold the decoded frames in memory to play them backwards. Our reverseVideo function takes the direct path only for clips of ten seconds or less, and for anything longer it splits the video into five-second segments, reverses each, and concatenates them, precisely to stay under the memory ceiling. The direct path even watches for OOM and Aborted in the error string to decide when to fall through to the segmented strategy.

Here is the honest comparison:

Browser (WASM)Native / server FFmpeg
File leaves deviceNoYes (upload)
Startup costDownload multi-MB WASM core onceNone for you; server provisioned
ParallelismWeb Workers, needs cross-origin isolationFull OS threads, no isolation needed
Memory ceilingWASM heap + tab limits (low GBs)Machine RAM
Hardware encoders (NVENC, QSV)NoYes, if present
Cost modelUser's CPUYour servers and their bandwidth

There is no hardware-accelerated encoding in the WASM build; everything runs on libx264 and friends in software, which is another reason the code leans on ultrafast presets. And the multi-megabyte core download is a one-time tax per session that a server-side tool never pays. For short-to-medium clips those tradeoffs are very much worth it. For hour-long 4K masters, a server still wins.

Where this shows up on loopaloo

Everything above is the engine behind several tools here. The Video Compressor uses the compressVideo path with the libx264 and CRF settings shown earlier. The Video Format Converter runs the per-format codec logic (libx264 for MP4, MOV, and MKV; libvpx-vp9 for WebM; and so on) through the same exec interface. And the Audio Format Converter is the minimal example from the virtual-filesystem section, writing your file in, running a one-line conversion, and reading the result back, all without a single byte going to a server.

Related Tools

Related Articles

Try Our Free Tools

200+ browser-based tools for developers and creators. No uploads, complete privacy.

Explore All Tools