The 99% Mystery: Why My ffmpeg.wasm App Stalls at the Finish Line
Source: Dev.to

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 spins, and after minutes the download finally appears as if nothing happened.
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 ~2 GB). Imagine you are converting a 500 MB video:
- The Peak: At 99 %, the WASM memory holds your 500 MB input file plus the newly generated 500 MB output file → 1 GB of WASM memory occupied.
- The Request: You call
engine.readFile(). JavaScript now tries to allocate a new 500 MBUint8Arrayto copy that data. - The GC Storm: The browser is now trying to manage nearly 1.5 GB–2 GB of massive, contiguous memory blocks. This triggers a “Stop‑The‑World” garbage‑collection event. The main thread locks up while the engine attempts to defragment memory to find a 500 MB hole. The UI freeze you see is this GC thrashing.
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: clear the desk before moving the big box.
// Optimized handover logic
// 1. FFmpeg is done. Before reading the output, delete the input file
await engine.deleteFile('input.mp4');
// 2. Now the WASM memory has breathing room; read the result
const data = await engine.readFile('output.mp4');
// 3. Immediately delete the WASM copy of the output
await engine.deleteFile('output.mp4');
// 4. Create a Blob from the JS buffer
const blob = new Blob([data.buffer], { type: 'video/mp4' });
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 cleanup shaves off crucial seconds of GC thrashing and prevents the tab from suffocating under heavy files.
Why I Didn’t Use WORKERFS or OPFS
- WORKERFS: 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, which wasn’t worth it.
- OPFS (Origin Private File System): Streams data directly to disk and is the future, but it requires a custom‑built FFmpeg core with
WASMFSsupport—something the official@ffmpeg/ffmpegpackage doesn’t provide out of the box.
The Takeaway: Know Your Handovers
When building high‑performance WebAssembly apps, remember: the most dangerous part of the pipeline is the data handover.
Moving large amounts of data between the WASM “world” and the JS “world” forces the browser to allocate massive contiguous memory blocks. If you don’t clean up internal state before making that request, you invite a GC storm.
VideoSnap is now significantly more stable, not because the math got faster, but because the memory lifecycle is managed with 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.