Using Heap Snapshots to Find Wasm-Related Leaks
This page answers one task: memory grows in a page that uses WebAssembly, and you suspect the JavaScript side — wrapper objects never freed, closures holding on to instances, old memories kept alive — so you want to use heap snapshots to find what is retained and why.
Prerequisites
- [ ] Chrome or Edge DevTools (Firefox and Safari have similar tools with different names).
- [ ] A reproducible scenario that leaks: an action you can repeat (open and close a document, run a job).
- [ ] A development build with readable names, if possible.
What heap snapshots can and cannot see
A heap snapshot captures every object on the JavaScript heap and the references between them. It sees the JavaScript side of a Wasm application:
wasm-bindgen wrapper objects (instances of exported classes), closures and callbacks, WebAssembly.Instance, WebAssembly.Module and
WebAssembly.Memory objects, and ArrayBuffers — including the one backing each memory, shown with its size. It does not see inside linear memory:
Rust Vecs, C malloc blocks and everything else allocated by the module’s own allocator are just bytes in one large ArrayBuffer.
So snapshots answer two kinds of question well. Is something on the JavaScript side leaking — wrappers, closures, buffers? And is a whole instance or memory being kept alive that should have been released? For leaks inside linear memory, you need the module’s allocator statistics or tools such as LeakSanitizer; snapshots only show that the memory’s buffer is large.
Step 1 — take three snapshots around the leaking action
The classic technique: take a baseline snapshot, perform the action several times, take a second snapshot, perform it again, take a third. Force garbage collection before each (the trash-can button in the Memory panel). Objects allocated between the first and second snapshots that are still alive in the third are leak candidates; anything that was freed is not.
Repeat the action enough times — ten or more — so leaked objects appear in numbers that stand out from noise: if one document open leaks one wrapper, ten opens leak ten.
Step 2 — use the comparison view
Select the third snapshot and choose “Comparison” against the first. Sort by “# Delta” or “Size Delta”. Look for constructor names you recognise:
- Your exported Rust structs, by class name (
Document,Decoder) — wrappers that were neverfree()d. ClosureorFunctionentries created by wasm-bindgen for callbacks.WebAssembly.InstanceorWebAssembly.Memory— whole instances kept alive.ArrayBufferwith large sizes — memories or copies of data.
A delta of exactly the number of repetitions for a class is a strong signal.
Step 3 — follow the retainer path
Click a leaked object and read the Retainers pane: the chain of references from a GC root that keeps it alive. Typical findings in Wasm apps:
- A wrapper stored in a cache, map or state store and never removed.
- An event listener closure capturing the wrapper, registered on
windowand never removed. - A wasm-bindgen
Closurepassed to JavaScript andforget()-ten, which keeps both the JavaScript function and the Rust state alive permanently. - A
WebAssembly.Instanceretained through its exports object captured in a long-lived closure.
Step 4 — connect JavaScript leaks to linear memory
A leaked wrapper is small on the JavaScript heap — a few dozen bytes — but it holds a pointer to a Rust object that may own megabytes in linear memory.
The snapshot shows the small wrapper; the large cost appears only as memory growth. Confirm the link: call a debug export that reports the allocator’s
live bytes before and after the repeated action. If live bytes grow by the size of N documents and the snapshot shows N leaked Document wrappers,
the wrappers are the cause. Fix by calling free() (or using using/Symbol.dispose where supported) when the object is no longer needed, and add a
FinalizationRegistry as a safety net.
Step 5 — check that instances and memories are released
Applications that create instances dynamically — per document, per worker job, per plugin — must release old ones. In the snapshot, filter by
WebAssembly.Memory and check the count. Each memory’s backing ArrayBuffer shows its full size, so a few retained old memories of 256 MB each are
obvious. Retainer paths often lead to a module-level variable or a closure created during instantiation. Note that WebAssembly.Module objects are
legitimately kept to instantiate quickly; it is old instances and memories that should disappear.
Reading wasm-bindgen glue in snapshots
wasm-bindgen’s glue keeps a JavaScript array (the heap table) of values that Rust code holds references to — JsValues owned by Rust. If Rust code
stores JsValues and never drops them, the referenced JavaScript objects stay alive through that table, and the retainer path shows the glue’s heap
array. With reference types enabled (--reference-types), these become entries in a Wasm table instead, which also appear in retainer paths. Either way,
the fix is on the Rust side: drop JsValues you no longer need.
Automating leak checks
Manual snapshot comparison is effective but slow. Once a leak is found and fixed, guard it with an automated test: Puppeteer and Playwright can take heap
snapshots or read performance.memory and the module’s live-byte counter after repeated actions, failing if counts grow. Tools such as memlab automate
the three-snapshot technique for scenarios defined in code, reporting leaked objects and retainer paths in CI.
Allocation timelines for leaks you cannot reproduce on demand
Some leaks have no single action to repeat — memory creeps up during normal use. The Memory panel’s “Allocation instrumentation on timeline” records
allocations over time and shows which ones survive: run the application through a realistic session, stop recording, and look at the blue bars that
remain at the end. Clicking a surviving allocation shows its constructor and stack trace at allocation time, which often points straight at the code
that created a wrapper or closure without a matching release. For Wasm apps, filter by your exported class names and by ArrayBuffer: a steady trickle
of surviving ArrayBuffers frequently means copies of data out of linear memory (for example Uint8Array.slice results stored in a cache) rather than a
leak inside the module.
Detached views and copies of linear memory
Two ArrayBuffer-related patterns show up repeatedly. Typed-array views created over memory.buffer become detached when memory grows; they keep a
reference to the old, detached buffer object but no longer its contents, so they are small — though code holding them is buggy for other reasons. Copies,
by contrast, are full-size: every slice() of a view over linear memory allocates a new ArrayBuffer of that size on the JavaScript heap. An image
editor that keeps undo history as slices of Wasm memory can retain hundreds of megabytes this way, all visible in snapshots as large ArrayBuffers
retained by the history array. Decide deliberately whether such copies belong in JavaScript or inside the module, where they can be compressed or bounded.
Snapshot hygiene
Snapshots of large applications take seconds and can be hundreds of megabytes. Close other tabs, use a clean browser profile without extensions (which add their own objects), and name snapshots after the step they represent. When sharing findings, export the snapshot file along with the scenario, so others can open the same comparison.
Expected output
The comparison view shows 10 Document wrappers and 10 Closure objects retained after ten open/close cycles; the retainer path leads to a resize
listener on window; removing the listener and calling doc.free() on close drops the deltas to zero, the allocator’s live bytes return to baseline after
each cycle, and a memlab scenario in CI guards the fix.
Gotchas
- Expecting to see Rust objects. Snapshots show only the memory’s buffer. Use allocator statistics for inside.
- Not forcing GC. Uncollected garbage looks like leaks. Collect before each snapshot.
- Too few repetitions. Leaks hide in noise. Repeat ten or more times.
Closure::forgetfor short-lived callbacks. Permanent leak. Keep and drop closures instead.- Small wrapper, big leak. Wrappers are tiny but hold large Rust objects. Check live bytes.
Performance note
Each leaked Document wrapper cost 64 bytes on the JavaScript heap but held 3.2 MB in linear memory; after ten cycles, the snapshot showed 640 bytes of
leaked objects while linear memory had grown by 32 MB.
Frequently Asked Questions
Can Firefox do this? Yes — its Memory panel takes snapshots with similar comparison and retainer views.
Does FinalizationRegistry fix leaks automatically?
It frees memory eventually when wrappers are collected, but not if wrappers are still retained; fix retention first.
Why is the Memory’s ArrayBuffer so large? It is the whole linear memory, which never shrinks; its size reflects the peak, not current use.
Do workers have separate snapshots? Yes — select the worker’s context in the Memory panel.
Why do large ArrayBuffers keep appearing in my snapshots?
Often copies of linear memory made with slice() and stored in JavaScript, such as undo history or caches; they are full-size allocations.
Related
- Finding Wasm memory leaks in the browser — the overall approach.
- Detecting forgotten free calls in wasm-bindgen — wrapper leaks.
- Freeing Wasm objects with FinalizationRegistry — the safety net.
- Catching leaks in Wasm unit tests — automated guards.
← Back to Memory Profiling & Leak Detection