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.
Prerequisites
- [ ] A
wasm-bindgenmodule 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.
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.
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.
Related
- Memory profiling & leak detection — where this leak sits among the others.
- wasm-bindgen deep dive — why the generated class has a free() at all.
- Finding Wasm memory leaks in the browser — the in-module counterpart.
- Passing JS objects to Rust with wasm-bindgen — the reference table on the other side of the boundary.
← Back to Memory Profiling & Leak Detection