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.

What a heap snapshot sees in a Wasm app Heap snapshots see JavaScript objects including wasm-bindgen wrappers, closures, instances, modules, memories and ArrayBuffers with their sizes and retainers. They do not see individual allocations inside linear memory, which appear only as one large ArrayBuffer. visible in snapshots wrapper objects and closures Instance, Module, Memory ArrayBuffers with sizes JS-side leaks invisible in snapshots Vec, Box, malloc blocks allocator free lists what fills linear memory use allocator tools

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 never free()d.
  • Closure or Function entries created by wasm-bindgen for callbacks.
  • WebAssembly.Instance or WebAssembly.Memory — whole instances kept alive.
  • ArrayBuffer with 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 window and never removed.
  • A wasm-bindgen Closure passed to JavaScript and forget()-ten, which keeps both the JavaScript function and the Rust state alive permanently.
  • A WebAssembly.Instance retained through its exports object captured in a long-lived closure.
A retainer path for a leaked wrapper The window object holds an event listener. The listener is a closure that captured a Document wrapper. The wrapper holds a pointer into linear memory where the Rust document lives. Removing the listener lets the wrapper be collected and, with free or a finalizer, the Rust memory be released. Window (GC root) event listeners resize listener closure Document wrapper captured variable __wbg_ptr pointer into linear memory Rust Document never freed

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::forget for 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.

Apparent versus real cost of ten leaked wrappers Kilobytes of JavaScript heap held by ten leaked Document wrappers compared with kilobytes of linear memory they kept alive. KB retained JS heap (wrappers) 0.6 KB linear memory (Rust documents) 32,768 KB

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.

← Back to Memory Profiling & Leak Detection