Why Wasm code can leak even when the JavaScript looks clean

JavaScript developers expect the runtime to clean up after them. Languages like C++ and Rust do not offer that luxury: memory must be explicitly allocated, and—just as important—explicitly freed before the last reference is discarded. Fail to match the two and you get leaks, or worse, use-after-free bugs.

Squoosh.app is a useful place to see this tension in practice. It ports image codecs written in C++ to WebAssembly (Wasm) so they can run in the browser. Working on its codec wrappers recently surfaced a pattern that is a textbook example of a use-after-free hiding in what looks like normal JavaScript.

Consider an ImageQuant wrapper that creates an object, exposes its result to JavaScript, and then frees it from the JS side. The C++ side returns a pointer to the image data; the JavaScript side wraps that pointer with new Uint8Array(...) and later frees it with a call like free_result().

The problem is in how Emscripten's typed_memory_view works. It does not copy the data. It creates a Uint8Array whose contents live directly in the Wasm memory buffer, with byteOffset and byteLength set to the pointer and the size you gave it. When you call free_result(), the underlying C free() marks that memory as available. From that point on, the Uint8Array you are still holding can be rewritten by the next call into Wasm that allocates memory. Some allocators might even zero the memory right away; Emscripten's default free does not, but that is an implementation detail you should not rely on.

Worse, even if the data survives, a future allocation may cause Wasm memory to grow. When WebAssembly.Memory grows via the JS API or a memory.grow instruction, the existing ArrayBuffer is invalidated, and every view into it—including your Uint8Array—is suddenly pointing at nothing.

That is easy to demonstrate in a DevTools console:

let memory = new WebAssembly.Memory({ initial: 1 });
let view = new Uint8Array(memory.buffer);
memory.grow(1);
// view is now detached; view.length is 0

Finally, multithreading could make the problem worse even if the JS code looks correct. With threads enabled, another thread could overwrite the data between free_result() and the clone.

Finding leaks and overflows with sanitizers

Suspecting the wrappers had real bugs, the natural next step is to build with Emscripten's AddressSanitizer support, which was added last year. Recompiling the codec with -fsanitize=address turns on pointer-safety checks. LeakSanitizer is included, but because text we are loading ImageQuant as a library (not a standalone program), there is no exit point for Emscripten to check all memory is freed. The sanitizer suite offers __lsan_do_leak_check and __lsan_do_recoverable_leak_check for exactly this situation; the former aborts the process on leaks, while the latter prints leaks and keeps running. For a library, the recoverable version is more suitable.

Expose that helper through Embind so it can be called from JavaScript after the image has been fully processed:

emscripten::function("leakCheck", &__lsan_do_recoverable_leak_check);

A first run of the leak check reports some small leaks, but the stack traces show only mangled function names. Recompiling with basic debug info (-g) produces readable traces that point to a RawImage being converted to a JavaScript value. The code returns RawImage instances to JavaScript but never deletes them on either side. There is no garbage collection bridge between JavaScript and Wasm yet—one is in development—so the manual .delete() method on the Embind wrapper is required. Calling delete() on those instances after processing eliminates the leaks.

Sanitizer builds of other Squoosh codecs expose a different class of bug. MozJPEG wrapper code writes past its allocated buffer, flagged as "outside of allocated boundaries." The root cause is that jpeg_mem_dest, the function used to allocate a memory destination for JPEG output, reuses existing non-zero values of its outbuffer and outsize parameters. The wrapper called it without zeroing those variables, so the code wrote to whatever random memory address was already stored there. Zero-initialising both before each call fixes the overflow. The leak check then runs clean, suggesting that MozJPEG has no leaks when only called once.

Hidden state leaks on repeated calls

A single sanitized run can miss leaks caused by global state. MozJPEG's bindings keep some state and results in global static variables, some of which are lazily initialised. Processing the same image a few times at different quality levels inside the same module surfaces a second leak: about 262 KB, equal to an entire sample image, coming out of jpeg_finish_compress.

It turns out that jpeg_finish_compress frees the compression structure but not the memory that was allocated via an earlier jpeg_mem_dest call. The compression structure knows about that memory, but libjpeg will not free it for you. The fix goes into the wrapper's free_result function, which then manually releases the data buffer.

Pursuing leaks one at a time reveals the bigger point: when JavaScript hands pointers back to WebAssembly, some bugs are invisible to the sanitizer entirely. A use-after-free where the misuse happens only in the JavaScript caller is undetectable to a C++ or Wasm-level tool. Those bugs surface only in production or after unrelated future changes.

Building a wrapper that cannot leak

The wrapper structure above is fragile because it leaves memory management to the caller. Restructure it so ownership is unambiguous.

First, fix the use-after-free on the front end. Clone the Uint8Array contents before freeing the Wasm memory that backs it:

const view = new Uint8Array(resultView.buffer);
// copy out of the Wasm heap before freeing
const data = view.slice();
free_result(ptr);

Next, remove shared global state from the wrapper. Each call to the codec should keep all its state in local variables. That changes free_result to accept the pointer directly; it no longer looks anything up in a singleton.

But since the wrapper already talks to JavaScript through Embind, a better design hides C++ memory management entirely. Move the final construction of the result bytes into the C++ side, building an Embind value that clones the data into JavaScript-owned memory before the function returns. With that single change, the JavaScript side receives an ordinary Uint8ClampedArray that is not backed by Wasm memory, and the leaked RawImage wrapper disappears entirely because no intermediate object is returned. A custom free_result binding becomes unnecessary, and JavaScript can treat the result as a plain garbage-collected value:

const result = codec.encode(image); // JavaScript result, no .delete() required

The hardened wrapper is cleaner and safer at the same time. Applied across the other Squoosh codecs—along with the zero-initialisation fix and the manual free of MozJPEG's destination buffer—the changes eliminate the systematic leak class described above. Details are available in the original pull request "Memory fixes for C++ codecs."

Practical lessons from the refactor

The debugging session highlights several general principles for anyone working with WebAssembly modules and JavaScript. These aren't new ideas, but the failure modes here show how easily they get ignored when the tooling can't point you at the problem.

  • Never hold a view of WebAssembly memory across calls. No matter what language produced the module, a Uint8Array or similar view backed by the module's heap is only valid for the duration of a single invocation. The implementation can move or invalidate that memory between calls, and you will not be able to reproduce or trace the resulting corruption through normal debugging. If you need the data later, copy it into a JavaScript-owned buffer and keep that.
  • Prefer safe wrappers or a safe language on the static side. Managing raw pointers by hand expands the area where a simple mistake can silently corrupt state. This does not eliminate bugs at the JavaScript ↔ WebAssembly boundary, but it does shrink the space for mistakes that are entirely inside the compiled code.
  • Run sanitizers during development regardless of source language. They flag more than the usual out-of-bounds or use-after-free in the static code. In this project they also surfaced cross-boundary problems, such as missing .delete() calls on objects exposed to JavaScript and invalid pointers passed in from the calling side.
  • Avoid handing unmanaged objects or memory to JavaScript when you can. JavaScript is garbage collected, and manual cleanup logic tends to get lost in a codebase that otherwise never thinks about it. Exposing raw pointers or similar constructs leaks the compiled language's memory model across the boundary, and those leaks are the hardest to notice until they compound.
  • Keep mutable state out of globals. This is standard advice everywhere, but it matters more here. A global holding state that is meant to be set once and read later will silently carry over between invocations or threads, and tracking down which call corrupted it is far harder than isolating state to the call itself.