DEV Community

Cover image for [Fixed] How to Solve the 99% Hang in ffmpeg.wasm Apps
Bob
Bob

Posted on

[Fixed] How to Solve the 99% Hang in ffmpeg.wasm Apps

TL;DR: The hang is caused by memory overlap. Delete your input file before reading the output.

I’ve been building VideoSnap, a tool that processes video entirely in the browser using ffmpeg.wasm. For a long time, I was haunted by a specific, frustrating bug: the "99% Trap."

A user uploads a file, the progress bar climbs smoothly, hits 99%... and then everything just stops.

The UI becomes unresponsive. The fan starts spinning. It doesn't crash with an "Aw, Snap!" error, but it hangs there, sometimes for minutes. Then, suddenly, the download pops up as if nothing happened.

I realized that the 99% mark isn't where FFmpeg is working—it's where the browser is fighting for its life.

The 99% isn't FFmpeg—it's the Handover

In ffmpeg.wasm, the progress bar tracks the FFmpeg execution. When it hits 99%, the heavy lifting of transcoding is actually done.

The "hang" happens during the handover: when you call engine.readFile() to pull the processed video out of the WebAssembly virtual memory (MEMFS) and into the JavaScript heap.

The "Memory Overlap" Problem

WebAssembly (currently) has a hard 32-bit memory limit (effectively ~2GB). Imagine you are converting a 500MB video:

  1. The Peak: At 99%, the WASM memory is holding your 500MB input file PLUS the newly generated 500MB output file. That’s 1GB of WASM memory occupied.
  2. The Request: You call engine.readFile(). JavaScript now tries to allocate a new 500MB Uint8Array to copy that data.
  3. The GC Storm: Your browser is now trying to manage nearly 1.5GB to 2GB of massive, contiguous memory blocks.

This triggers a "Stop-The-World" Garbage Collection (GC) event. The browser's Main Thread locks up completely. It is desperately trying to defragment memory to find a 500MB hole. This intense "GC thrashing" is why the UI freezes before the file finally breaks free.

The "Surgical" Fix: Breaking the Overlap

Once I understood that the stall was caused by the simultaneous existence of the input and output files in MEMFS, the fix became obvious.

I needed to clear the desk before trying to move the big box.

I implemented what I call Surgical Memory Management:

// The optimized handover logic:

// 1. FFmpeg is done. Before we even THINK about reading the output, 
// we must kill the input file to free up hundreds of MBs in WASM.
await engine.deleteFile('input.mp4'); 

// 2. Now that the WASM memory has breathing room, we read the result.
// The browser can allocate the JS buffer without a massive GC fight.
const data = await engine.readFile('output.mp4');

// 3. The millisecond we have the data in JS, we nuke the WASM output copy.
await engine.deleteFile('output.mp4');

// 4. Now WASM is empty, and we only hold the file in the JS heap.
const blob = new Blob([data.buffer], { type: 'video/mp4' });
Enter fullscreen mode Exit fullscreen mode

By reordering these deletions, I eliminated the massive memory overlap at the exact moment the browser needs memory the most. The 99% hang doesn't magically vanish—it still takes time for the browser to allocate large JS buffers—but this surgical cleanup shaves off crucial seconds of GC thrashing. More portantly, it keeps the browser tab from quietly suffocating under heavy files.

Why I didn't use WORKERFS or OPFS

I explored other options, but they all had catch-22s:

  • WORKERFS: It mounts files without copying them, which sounds perfect. But it uses a synchronous I/O bridge that makes FFmpeg run significantly slower. I traded memory for a massive speed penalty. Not worth it.
  • OPFS (Origin Private File System): This is the future. It streams data directly to disk. But it requires a custom-built FFmpeg core with WASMFS support, which is a massive engineering undertaking that the official @ffmpeg/ffmpeg doesn't support out-of-the-box yet.

The Takeaway: Know Your Handovers

If you are building high-performance WebAssembly apps, remember: the most dangerous part of the pipeline is the data handover.

When you move large amounts of data between the WASM "world" and the JS "world," the browser doesn't see a file—it sees a massive, contiguous memory allocation request. If you don't clean up your internal state before you make that request, you're asking for a GC storm.

VideoSnap is now significantly more stable, not because I made the math faster, but because I managed the memory lifecycle with more precision.


I’m the builder of VideoSnap. I write about the messy reality of building high-performance tools in the browser. Follow for more deep dives.

Top comments (0)