Detecting Forgotten free() Calls in wasm-bindgen

This guide finds the leak that lives at the boundary rather than inside the module: a wasm-bindgen object whose free() was never called, holding an allocation in linear memory that nothing will ever release.

The failure is silent by construction. The JavaScript handle is garbage collected normally, so nothing on the JavaScript side looks wrong; the allocation it pointed at stays live because the collector has no idea it exists. A page can leak megabytes this way while every JavaScript-level tool reports a clean heap.

The handle goes; the allocation stays When a JavaScript variable holding a bound object goes out of scope, the garbage collector reclaims the small handle object. The allocation it referenced inside linear memory is untouched, because the collector cannot see into that buffer and free is the only thing that releases it. before: handle in scope const m = new Matrix(1024, 1024) 4 MB in linear memory allocated by the constructor after: handle collected GC reclaims a few dozen bytes still 4 MB, now unreachable nothing can ever free it The pointer that would have freed it was inside the handle, which is gone — this is a leak with no remaining reference to it.

Prerequisites

  • [ ] A wasm-bindgen module exposing at least one class
  • [ ] The ability to add a small amount of instrumentation to the JavaScript side
  • [ ] FinalizationRegistry, available in every current browser
  • [ ] A workload that constructs and disposes objects repeatedly

Step-by-step procedure

1. Count constructions against disposals

The simplest instrumentation is a counter pair around the class:

let live = 0;

export function trackMatrix(Matrix) {
  return class Tracked extends Matrix {
    constructor(...args) { super(...args); live++; }
    free() { live--; return super.free(); }
  };
}

export const liveObjects = () => live;

After a workload that should have disposed everything, liveObjects() should be zero. Anything else is the count of leaked objects, and the number often identifies the code path immediately.

2. Add a finalisation warning

FinalizationRegistry tells you when a handle was collected — which, for a bound object, means free() was never called:

const registry = new FinalizationRegistry((label) => {
  console.warn(`wasm object collected without free(): ${label}`);
});

export function watch(obj, label) {
  registry.register(obj, label);
  return obj;
}
const m = watch(new Matrix(1024, 1024), "Matrix@transform");
try { /* … */ } finally { m.free(); }

Note the ordering subtlety: an object that was freed still gets collected later and will still fire the callback unless you unregister it. Wrap free() to call registry.unregister(obj) so only genuine leaks warn.

3. Make disposal structural

The best fix is the one that removes the discipline requirement:

export function withMatrix(rows, cols, fn) {
  const m = new Matrix(rows, cols);
  try {
    return fn(m);
  } finally {
    m.free();
  }
}

const total = withMatrix(1024, 1024, (m) => { m.fill(1); return m.sum(); });

A caller cannot forget what the API does not let them hold.

4. Cross-check against linear memory

const before = instance.exports.memory.buffer.byteLength;
for (let i = 0; i < 100; i++) runWorkload();
console.log({ live: liveObjects(), grew: instance.exports.memory.buffer.byteLength - before });

5. Check closures too

Closure::wrap(...).forget() is a deliberate leak, and an appropriate one for a listener that lives as long as the page. Used for a short-lived handler it leaks a table slot on every registration — audit every forget() in the codebase and confirm each is intentional.

Expected output

// leaking
> runWorkload(); runWorkload(); runWorkload();
> liveObjects()
3
[warn] wasm object collected without free(): Matrix@transform
[warn] wasm object collected without free(): Matrix@transform

// after wrapping the usage in withMatrix
> runWorkload(); runWorkload(); runWorkload();
> liveObjects()
0

Two signals agreeing is what makes this convincing: the counter says three objects are still live, and the registry says two of them were dropped without disposal. The third is presumably still referenced — which is a different problem, and one the counter alone would not have distinguished.

Two signals, three situations A live count above zero with no warnings means objects are still referenced somewhere. Warnings mean objects were dropped without being freed. Both at once means some of each, which is common and needs separating before fixing. count 0, no warnings everything constructed was freed healthy count > 0, no warnings objects are still referenced a cache, or a retained array a retention problem, not a free problem warnings fired dropped without free() the allocation is unreachable a genuine, unrecoverable leak The middle case is often legitimate. The right-hand case never is, which is why the registry warning is the higher-value signal.

Gotchas

FinalizationRegistry callbacks are not guaranteed. The specification does not promise they run, and they are certainly not prompt. Treat a warning as reliable evidence of a leak and the absence of warnings as weak evidence of health.

Warnings fire for objects that were freed. You did not unregister on free(). Wrap it.

The counter goes negative. free() was called twice on the same object. That is a bug in its own right — a double free — and in a real allocator it can corrupt the heap.

Objects leak only on the error path. The disposal was after the code that threw. This is exactly what try/finally prevents, and why the wrapper function pattern is worth the small amount of ceremony.

A returned object is never freed by its caller. Any API returning a bound object is asking callers to remember. Prefer returning plain data, or a wrapper that disposes automatically.

Remove the discipline requirement An API that hands the caller an object relies on them remembering to dispose it, including on error paths. An API that takes a callback owns the lifetime itself, so the object is freed whether the callback returns or throws. const m = new Matrix(...) the caller must remember free() and must remember it on the error path too withMatrix(rows, cols, fn) the wrapper owns the lifetime freed on return and on throw The second form costs one function and removes an entire class of leak from every future call site.

Performance note

The instrumentation is cheap: a counter increment per construction and a registry registration, which is a weak reference and a callback registration rather than anything heavyweight. Leaving the counter in production is reasonable; leaving the registry warnings in is a judgement call, since a noisy console in the field has its own cost.

The leak itself is the expensive part. Every leaked object holds its full allocation for the life of the page, and because linear memory never shrinks, the footprint is permanent even after the leak is fixed at run time. That asymmetry — cheap to detect, expensive to leave — is the argument for wiring the live-object assertion into a test rather than relying on review.

Designing the API so the problem cannot arise

Detection is a fallback. The better outcome is an interface where forgetting is impossible, and three patterns get most of the way there.

Scope-bound helpers. The withMatrix(rows, cols, fn) shape shown earlier owns the lifetime entirely. Callers never hold the object beyond the callback, and the finally handles the error path. For anything used in more than one place this is worth writing once.

Return data, not objects. A function returning a Float64Array of results creates nothing that needs disposing. A function returning a bound Matrix creates something that does. Where the caller only wants values, the first shape removes the question:

#[wasm_bindgen]
pub fn multiply_into(a: &[f64], b: &[f64], out: &mut [f64]) { /* … */ }

Pool and reuse. Where an object genuinely must persist — a decoder, a parser context — create a fixed number at startup and hand them out, rather than constructing per operation. A pool of four objects that live for the session cannot leak, however careless the calling code is.

Between them these cover most usage, and what remains is the small number of genuinely long-lived objects whose lifetimes are worth managing explicitly. That is a much smaller surface to review than “every construction in the codebase”.

Auditing an existing codebase

For code already written, a mechanical sweep finds most of the risk quickly. Search for constructions of bound classes and check each against three questions: is there a matching free(), is it on the error path as well, and could the object outlive the function that created it?

grep -rn "new \(Matrix\|Decoder\|Context\)(" src/ | wc -l     # constructions
grep -rn "\.free()" src/ | wc -l                              # disposals
grep -rn "\.forget()" src/                                    # deliberate leaks — check each

Numbers that differ substantially are worth investigating, though they will not match exactly — disposal inside a helper serves several constructions. The forget() list is the one to read line by line: each is a deliberate leak that was correct when written, and some of them are attached to listeners that no longer live as long as the page.

Adding the live-object counter and asserting zero after a representative workload turns that audit into something that stays done. It is a few lines in a test, and it is the difference between fixing these once and fixing them every few months.

Frequently Asked Questions

Why does wasm-bindgen not free automatically? Because JavaScript’s garbage collector cannot see into linear memory, and there was no reliable hook to run code when a handle is collected at the time the design was made. FinalizationRegistry is that hook, but its non-deterministic timing makes it a diagnostic rather than a disposal mechanism.

Will the Wasm GC proposal fix this? For objects managed by the engine’s collector, yes. For data your module allocates in linear memory the ownership question remains, so the discipline still applies to buffers and hand-managed structures.

Is there a lint for it? Not generally, though a project-specific rule banning bare new Wasm*() outside a wrapper function is straightforward and effective.

Does this apply to values returned from the module, not just constructed ones? Yes, and those are the easier ones to miss. A function returning a bound object hands the caller something to dispose, and the call site often reads like an ordinary value assignment. Auditing return types as well as constructors is part of the sweep, and a return type that could have been plain data is usually worth changing rather than documenting.

Does the counter approach work with generated subclasses? Yes, and wrapping the generated class as shown keeps it transparent to callers — they construct what looks like the original type.

← Back to Memory Profiling & Leak Detection