DEV Community

Cover image for FFmpeg.wasm in production: deadlocks, a 2 GB ceiling and a codec that lies to users
Maksim Popkov
Maksim Popkov

Posted on

FFmpeg.wasm in production: deadlocks, a 2 GB ceiling and a codec that lies to users

FFmpeg.wasm can hang forever with no error, no log and no way out except killing the tab. That happened to roughly one in ten heavy jobs before I shipped a watchdog, and it is only the third-worst problem I hit while building a media converter that runs entirely in the browser.

The premise was simple enough. Converting media is something people do dozens of times a week: drone footage into MP4 for a messenger, iPhone HEIC into JPEG for a website, an interview recording trimmed and compressed. The usual path is one of the hundreds of online converters: upload a half-gigabyte file, wait while it crawls up to the server, wait in the queue, download the result. And your files spend that whole time on somebody else's machine, which few people stop to think about (and they should, if a corporate contract or a personal video is involved).

So I tried removing the server from that scheme entirely. No uploads, no queues, no server costs. That is how BrowsersKit came about: a set of tools (media converter, PDF, math, Python sandbox) that run fully on the client. Here is what that cost in engineering terms.

Three things you will get out of this post if you work with WASM in the browser:

  • how to detect and recover from FFmpeg.wasm thread pool deadlocks
  • how to process multi-gigabyte files inside a 32-bit heap using WORKERFS
  • why I ship VP8 when the user asks for VP9, and why that was the right call

Stack and architecture: why it is built this way

Vanilla JS and MPA instead of React/SPA

The first decision that might look strange: no React, no Vue, no Svelte. The whole project is written in plain JavaScript (ESM modules) and built with Vite. The reason is mundane: there is no complex reactive state here. The real weight sits in the WASM engines (FFmpeg, Pyodide), and I did not want to drag in 100+ KB of framework just to wrap them. Every extra kilobyte of bundle slows down First Contentful Paint, and for utility sites that is critical: a person arrives from Google to solve one specific task, and if the page has not loaded within a second, they are already on a competitor's site.

Architecture: Multi-Page Application. Each tool (/pdf/split/, /media/video-to-gif/, /math/equations/) has its own index.html. At build time Vite automatically finds every index.html in the project and turns each one into a separate Rollup entry point:

function findHtmlEntries(dir = ROOT, acc = {}) {
  for (const name of readdirSync(dir)) {
    if (name.startsWith('.') || SKIP_DIRS.has(name)) continue;
    const full = join(dir, name);
    if (statSync(full).isDirectory()) {
      findHtmlEntries(full, acc);
    } else if (name === 'index.html') {
      const rel = relative(ROOT, dir);
      const key = rel === '' ? 'main' : rel.replace(/[\\/]/g, '-');
      acc[key] = full;
    }
  }
  return acc;
}
Enter fullscreen mode Exit fullscreen mode

To add a new tool, all you have to do is create a folder with an index.html. No config edits required.

Layered isolation

The codebase is split strictly into layers, and imports flow only from the top down:

[pages]  →  [ui]  →  [core / engines / utils]
Enter fullscreen mode Exit fullscreen mode

Modules in core/, engines/ and utils/ are not allowed to touch the DOM. No document, no window (feature detection aside). The entire visual part lives in ui/, while the core stays a set of pure functions that can be tested in Node with Vitest without launching a browser. This is not a whim: circular imports in an MPA Rollup build produce undefined behaviour, something painfully familiar to anyone who has ever built "circularly linked" React components in a monorepo.

Technical details: three cases from production code

Case 1. Watchdog: how to detect that FFmpeg.wasm has quietly died

The main problem with FFmpeg.wasm (@ffmpeg/core-mt, the multithreaded build) is thread pool deadlocks. Emscripten creates a limited pool of pthreads (roughly matching navigator.hardwareConcurrency). If the decoder and the encoder request more threads at the same time than the pool holds, pthread_create blocks the runtime. Forever. No errors, no logs, no external signs whatsoever. The user sees a frozen progress bar. The only way out is to kill the tab.

So I wrote a watchdog that tracks FFmpeg's pulse. Every log message and every progress event refreshes the _lastActivity timestamp. If FFmpeg stays silent for more than 180 seconds in a row, that is not "slow encoding", that is a hang. A live encoder prints statistics several times a second, even with demanding codecs.

const WATCHDOG_IDLE_MS = 180_000;

async function execWatched(ff, args) {
  _lastActivity = Date.now();
  let timer;
  const watchdog = new Promise((_, reject) => {
    const tick = () => {
      if (Date.now() - _lastActivity > WATCHDOG_IDLE_MS) {
        reject(new Error(
          'FFmpeg hang detected (no activity for several minutes). ' +
          'Restarting in a safe mode…'
        ));
        try { ff.terminate(); } catch (_) {}
        return;
      }
      timer = setTimeout(tick, 10_000);
    };
    timer = setTimeout(tick, 10_000);
  });
  try {
    return await Promise.race([ff.exec(args), watchdog]);
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

When the watchdog fires, the engine does not simply restart, it walks down a "reliability ladder". The idea is that if a job failed in normal mode, it makes sense to try again with more conservative settings:

const MODES = [
  { label: 'rt.status.ffmpeg.processing' },                        // normal
  { threads: '1', label: 'rt.status.ffmpeg.retry' },               // single thread
  { threads: '1', downscale: true, label: 'rt.status.ffmpeg.lowmem' }, // + downscale
  { variant: 'st', downscale: true, label: 'rt.status.ffmpeg.safe' }, // single-threaded build
];
Enter fullscreen mode Exit fullscreen mode

The last rung is the single-threaded core build (core-st), where a deadlock is physically impossible, because there is no thread pool in it at all. Slow, but guaranteed to reach the finish line.

The watchdog threshold was a separate source of pain. It was originally set to one minute, and that turned out to be too little. On a weak WASM core, HEVC (libx265) really can go quiet for more than a minute between statistics lines, not because it has hung but because it is working. False watchdog triggers were aborting legitimate encodes, and I only caught this in E2E tests with real 4K files. Raising the limit to 180 seconds fixed it: the false positives stopped, and real deadlocks are still caught (during a deadlock there is no activity at all, not even once a minute).

Case 2. Two ways to mount files: MEMFS vs WORKERFS

WebAssembly is compiled for a 32-bit architecture. The maximum heap size is around 2 GB. If a user loads a 1.8 GB video file into MEMFS (the standard Emscripten file system, which keeps everything in RAM) and FFmpeg then allocates buffers while encoding, the total goes past 2 GB and the runtime dies with an OOM (Out of Memory). On mobile devices the problem is even sharper: the browser may kill the tab at 500 MB.

The solution is a hybrid approach. Files up to 128 MB go through MEMFS (the fast multithreaded path), anything bigger is handed over to WORKERFS, which mounts the browser File object as a virtual file system:

export const MEMFS_MAX_BYTES = 128 * 1024 * 1024;

// ...inside attemptJob:
const useWorkerFS = file.size > MEMFS_MAX_BYTES;

if (useWorkerFS) {
  try {
    await ff.createDir(MOUNT_DIR);
    await ff.mount('WORKERFS', { files: [safe] }, MOUNT_DIR);
    mounted = true;
    inPath = `${MOUNT_DIR}/${inName}`;
  } catch (mountErr) {
    // Fallback: if WORKERFS is unavailable, write to MEMFS and hope for the best
    console.warn('[ffmpeg] WORKERFS unavailable, writing to MEMFS:', mountErr);
    await ff.writeFile(inName, new Uint8Array(await file.arrayBuffer()));
    wrote = true;
    inPath = inName;
  }
}
Enter fullscreen mode Exit fullscreen mode

WORKERFS reads data from disk in chunks instead of copying the whole file into the WASM heap. That removes the OOM problem for files of any size. There is a trade-off, though: WORKERFS works through a bridge to the browser's main thread, so encoding is limited to a single thread:

export function threadLimit(mode, hc, mounted) {
  if (mounted) return '1';      // WORKERFS → always 1 thread
  const cores = Math.max(1, hc || 4);
  return mode === 'max'
    ? String(Math.min(8, Math.max(1, cores - 1)))
    : String(Math.min(4, Math.max(1, Math.floor(cores / 2))));
}
Enter fullscreen mode Exit fullscreen mode

Eco mode (the default) takes half of the cores so that the user can keep browsing while conversion runs in the background. Max mode takes all cores minus one. WORKERFS: one core, no alternatives.

Case 3. VP9 in WASM: when a codec physically does not work

This is a story about accepting unpleasant engineering decisions. The user picks "WebM · VP9", a popular open format for the web. I call libvpx-vp9 through FFmpeg.wasm. And I get:

  • a TypeError in some thread configurations,
  • an endless hang in others,
  • a core crash with RuntimeError: unreachable in yet others.

And no combination of arguments (-threads 1, -row-mt 0, -tile-columns 0 -frame-parallel 0) helps. Not even on a synthetic one-second clip. I checked: those very same arguments in native (desktop) FFmpeg work instantly. This is a bug in the WASM build of libvpx, not in my flags.

So what do you do? The project's promise is to see the task through to the end. I settled on a compromise: the software FFmpeg path behind the "WebM · VP9" menu item actually encodes VP8. Same WebM container, same codec pairing with Opus, and visually the user will not spot the difference. Meanwhile libvpx (VP8) does its job cleanly under exactly the same conditions.

case 'webm-vp9':
  // libvpx-vp9 is unstable in this emscripten build; it has been
  // experimentally confirmed that ANY combination of arguments either
  // crashes the core or hangs forever. The software path encodes VP8.
  // The fast hardware path (WebCodecs) still tries real VP9 anyway.
  return [
    '-c:v', 'libvpx',
    ...(rate ? rate : ['-b:v', VP8_BR[q]]),
    '-deadline', 'good',
    '-cpu-used', '5',
  ];
Enter fullscreen mode Exit fullscreen mode

The fast hardware path (through the WebCodecs API) still tries real VP9 first, and there it works, because encoding runs natively through the browser's GPU/CPU rather than through WASM.

There is a similar problem with HEVC (libx265): this codec ignores FFmpeg's -threads and creates its own thread pools (WPP, frame threads, lookahead). In an Emscripten build the pthread pool is finite, and one extra pthread_create blocks the runtime forever. The fix is to force x265 into a fully single-threaded mode with -x265-params pools=none:frame-threads=1. Slower, but it finishes.

Two-track image conversion

For images I implemented hybrid routing. If the user is simply converting JPG to WebP with no extra settings (cropping, rotation, filters), there is no point in spinning up a 30 MB WASM engine. A fast path through WebCodecs ImageDecoder plus Canvas handles it instead:

export async function convertImage(file, targetMime, quality) {
  let bitmap;
  if (ENV.imageDecoder) {
    try {
      const dec = new ImageDecoder({
        data: await file.arrayBuffer(),
        type: file.type || 'image/*',
      });
      const { image } = await dec.decode();
      bitmap = image;
    } catch (_) {
      bitmap = await createImageBitmap(file);
    }
  } else {
    bitmap = await createImageBitmap(file);
  }
  // ...draw on canvas, encode back via convertToBlob
}
Enter fullscreen mode Exit fullscreen mode

Hardware decoding via WebCodecs, rendering on an OffscreenCanvas, hardware encoding on the way back. No WASM, no waiting for an engine to download. A 20-megapixel photo is converted in milliseconds. FFmpeg is brought in only when a format that canvas cannot handle is required (TIFF, BMP, ICO), or when filters and cropping are enabled.

The selection strategy is trivial:

export function chooseStrategy(category, formatId, opts) {
  if (category === 'image' && image.canUseFastPath(formatId, opts)) {
    return 'webcodecs';
  }
  return 'ffmpeg';
}
Enter fullscreen mode Exit fullscreen mode

The problem of loading heavy cores

The ffmpeg-core.wasm core (multithreaded build) weighs about 31 MB. The free Cloudflare Pages tier does not allow uploading files larger than 25 MB. The solution looks obvious: slice the file into chunks (*.part1, .part2, .part3) and glue them back together in the browser. That is exactly what I did. And I immediately got crashes on mobile devices.

Here is why: while the parts are being merged, memory simultaneously holds three ArrayBuffers of the chunks (~30 MB), the combined Uint8Array (~31 MB), a Blob made from it (~31 MB) and the WASM runtime itself during initialisation (~60 MB). That adds up to 150+ MB in one go. On an iPhone or a budget Android device that is a guaranteed OOM.

The final solution: the sliced .part files were kept only for local development and E2E tests, while in production the cores are loaded as whole files straight from a CDN (unpkg.com). The URL is versioned and the Service Worker caches it cache-first, so after the first visit the 64 MB of cores are never downloaded again:

export const VENDOR = {
  mt: isProd
    ? 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/umd'
    : '/vendor/core-mt',
  st: isProd
    ? 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'
    : '/vendor/core-st',
};
Enter fullscreen mode Exit fullscreen mode

The Service Worker caches only immutable things: the cores from the CDN and hashed bundles from /assets/. HTML is deliberately left uncached so that site updates reach users instantly:

function isCacheable(url) {
  if (url.origin === self.location.origin) {
    return url.pathname.startsWith('/vendor/') || url.pathname.startsWith('/assets/');
  }
  if (url.origin === 'https://unpkg.com') return url.pathname.startsWith('/@ffmpeg/');
  if (url.origin === 'https://cdn.jsdelivr.net') return url.pathname.startsWith('/pyodide/');
  return false;
}
Enter fullscreen mode Exit fullscreen mode

What I learned

Browser WASM is not a silver bullet. It lets you do incredible things (a full FFmpeg inside your browser!), but it brings along a whole class of problems that native applications simply do not have: thread pool deadlocks, a 2 GB memory ceiling, individual codecs being unstable. Without the watchdog and the retry ladder, the project would have been unusable for real people. Roughly one heavy job in ten would hang with no diagnostics at all.

WORKERFS is an underrated Emscripten feature. I could not find a single article where anyone had used it with FFmpeg.wasm. Every example on the internet just calls writeFile with arrayBuffer(), which guarantees a crash on files over a gigabyte. WORKERFS is the only way to process large files without OOM, even at the price of single-threading.

Not all codecs are created equal. libvpx-vp9 and libx265 in a WASM build behave nothing like their native counterparts. You end up sacrificing "honesty" for stability. A user who just wants to convert a video does not care whether VP9 or VP8 is inside; what matters is that a working file comes out.

SEO for utility sites is architecture, not marketing. At build time the project generates hundreds of pages: 17 SEO conversion pairs (/converter/jpg-to-png/, /converter/mp4-to-gif/ and so on) × 12 languages = 200+ URLs for the converter alone. Plus the tools for PDF and math. Plus sitemap.xml and robots.txt, generated automatically by the same Vite plugin. All of it happens during vite build, with no runtime rendering on a server, because there is no server.

Instead of a conclusion

BrowsersKit is a side project I have been building solo for a month and a half. 15,000 lines of code, unit test coverage in place (Vitest), E2E tests running in Docker via Playwright. The entire hosting setup is the free Cloudflare Pages tier.

The main conclusion I have come to over that time: browser technologies have matured to the point where server-side media processing is simply unnecessary for most user tasks. SharedArrayBuffer, WebCodecs, WORKERFS, OffscreenCanvas, all of it already works in production, as long as you know where to soften the landing.

I hope the solutions described here (the watchdog with its retry ladder and the hybrid file system in particular) prove useful to anyone working with WASM in the browser. The project itself is open for anyone to use: browserskit.com

If you have hit FFmpeg.wasm deadlocks yourself, I would like to hear what threshold you settled on for detecting them, and whether anyone has managed to get libvpx-vp9 to behave in an Emscripten build. Drop it in the comments.

Top comments (0)