Multithreaded WebAssembly: Fixing the Two Pain Points That Kill Scaling

For CPU-bound code, WebAssembly multithreading on the web can scale just as well as it does natively. The web platform is a capable host for parallel work; web workers provide the underlying thread model, and modern browsers execute Wasm efficiently enough that a purely computational workload shows the steady, near-linear speedups you would expect from adding more cores.

But multithreaded Wasm applications that do real work — allocations, file I/O, or both — have historically hit two bottlenecks that can destroy that scaling. Both problems are in the length of the list of system services a program needs beyond raw arithmetic. This article looks at the two pain points and the Emscripten features that address them: mimalloc for heap management and WasmFS for filesystem access.

The malloc Bottleneck

Linear-memory languages (C, C++, Rust, Zig) rely on malloc and free for all dynamic memory. Emscripten ships with dlmalloc as the default allocator, which is compact and efficient for single-threaded usage. However, it uses a single global allocator with a lock taken on every malloc and free call. In a multithreaded program, that lock becomes a contention point: many threads can end up waiting for the allocator while other threads hold it.

In a malloc-heavy benchmark, the results are stark. Instead of running faster with more cores, performance actually degrades as the allocator lock serializes what should be parallel work.

Switching to mimalloc

Emscripten has added support for mimalloc, Microsoft's allocator designed for threads. It is not invoked by default — dlmalloc remains the default because of its small footprint — but enabling it is a compile-time flag change:

emcc -sMALLOC=mimalloc

With mimalloc, the same malloc-heavy benchmark scales cleanly: each additional core produces a meaningful speedup, matching the pure-computation baseline. Notably, the improvements are not limited to multithreaded programs. In single-threaded runs of the benchmark, mimalloc completed in approximately half the time of dlmalloc (1466 ms vs. 2660 ms), thanks to its more sophisticated allocation strategies.

The tradeoff is code size and memory usage. mimalloc is a more complex allocator, so it adds overhead in both dimensions. But for applications that are allocation-heavy, the performance payoff can be dramatic.

The File I/O Problem Child: JS FS

File I/O is another common scaling failure point, even for operations as simple as printf, which writes to standard output under the hood. Emscripten's original filesystem implementation — "JS FS," so called because it lives in JavaScript — uses a simple threading model: only the main thread can access files. When a background thread needs any file operation, it sends a proxied request to the main thread and then blocks until that request is processed.

That model works well enough for programs that do all filesystem work on the main thread, which is a common pattern. But for background threads, every file operation means a cross-thread round trip. The main thread does work on behalf of other threads, creating latency; meanwhile, worker threads can spin waiting for the main thread to be free. This pattern can even lead to deadlocks if the main thread is waiting on a worker.

WasmFS: Files in Shared Memory

Emscripten's new filesystem, WasmFS, removes the main-thread dependency. It is written in C++ and compiled to Wasm, so there is no JavaScript barrier for most operations. Files are stored in Wasm linear memory, which is shared by all threads. Any thread can read or write files directly, with no need to proxy anything to the main thread.

Benchmarking makes the difference obvious. Comparing the same filesystem workload on the main thread versus on a single pthread:

A bar chart titled File system performance compares execution time in milliseconds (y-axis, labeled lower is better) for JS FS and WasmFS across two categories: main thread and pthread (x-axis). The JS FS takes significantly longer in the pthread case, while WasmFS remains consistently low in both cases.

With JS FS, running on a pthread is over an order of magnitude slower than on the main thread, because every operation requires a message, a queue, and a waiting period. WasmFS eliminates that per-operation cross-thread communication: the gap between main-thread and pthread performance essentially vanishes, making it 32 times faster than JS FS when invoked from a worker.

There is also a 2-times improvement on the main thread itself. The JS FS calls into JavaScript for every filesystem operation; WasmFS stays native unless it legitimately needs a Web API. And when JavaScript is unavoidable, WasmFS can use a helper worker, keeping that overhead off the main thread and the UI event loop.

Enabling WasmFS is, again, a flag change:

emcc -sWASMFS

WasmFS is considered production-stable, though it does not yet implement every interface of the JS FS. It does, however, add new capabilities not present in the old filesystem, including support for the origin private file system (OPFS) for persistent storage.

When to Use These Features

Both mimalloc and WasmFS are drop-in recompilations: two flags, no source changes. They are worth trying in any application that is multithreaded and does substantial allocation or file activity. And as the benchmarks show, they can even help single-threaded programs, because each brings optimizations that have nothing to do with thread scaling.