Catching Leaks in Wasm Unit Tests
This page answers one task: memory leaks in a WebAssembly module are found by users after hours of use, long after the change that caused them. You want the test suite to fail as soon as a test leaves memory allocated that it should have freed.
Prerequisites
- [ ] A Wasm module with unit tests run from Rust (
wasm-bindgen-test,cargo teston WASI) or JavaScript (Vitest, Jest, Node’s runner). - [ ] The ability to build a test variant with allocation counters.
- [ ] A test runner that supports per-test setup and teardown hooks.
The balance check
A leak is memory allocated and never freed. In a unit test, that has a precise test: record the allocator’s live bytes (or live allocation count) before the test, run the test, release everything the test created, and check that live bytes return to the starting value. If they do not, the difference is leaked memory — attributable to that test and therefore to a small piece of code.
The technique is simple, but three things make it practical: a counting allocator in the test build, a test wrapper that does the bookkeeping, and a way to exclude memory that is meant to persist — caches, lazily initialised statics, interned strings — so the check does not cry wolf.
Step 1 — count allocations in the test build
In Rust, enable a counting global allocator behind a feature used only in tests:
#[cfg(feature = "leak-check")]
pub mod leak {
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicIsize, Ordering::SeqCst};
pub static LIVE_BYTES: AtomicIsize = AtomicIsize::new(0);
pub static LIVE_ALLOCS: AtomicIsize = AtomicIsize::new(0);
pub struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
LIVE_BYTES.fetch_add(l.size() as isize, SeqCst); LIVE_ALLOCS.fetch_add(1, SeqCst); System.alloc(l)
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
LIVE_BYTES.fetch_sub(l.size() as isize, SeqCst); LIVE_ALLOCS.fetch_sub(1, SeqCst); System.dealloc(p, l)
}
}
#[global_allocator]
static A: Counting = Counting;
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn live_bytes() -> f64 { LIVE_BYTES.load(SeqCst) as f64 }
}
realloc defaults to alloc-copy-dealloc through these methods, so counts stay consistent. For C and C++, wrap malloc/free with a counter, or read
mallinfo().uordblks before and after.
Step 2 — wrap Rust tests with a balance assertion
#[cfg(all(test, feature = "leak-check"))]
fn no_leak<F: FnOnce()>(f: F) {
warm_up_statics(); // touch lazy statics once
let before = leak::LIVE_BYTES.load(SeqCst);
f();
let after = leak::LIVE_BYTES.load(SeqCst);
assert_eq!(after - before, 0, "test leaked {} bytes", after - before);
}
#[wasm_bindgen_test]
fn parse_and_drop_document() {
no_leak(|| {
let doc = Document::parse(SAMPLE).unwrap();
assert_eq!(doc.pages().len(), 3);
}); // doc dropped inside the closure
}
Run leak-checked tests serially: parallel tests in one process share the counters, and on wasm32 tests already run on one thread.
Step 3 — check from JavaScript for wrapper and glue leaks
Leaks often happen at the boundary — a wasm-bindgen object never freed, a string passed in and not released by hand-written glue. Check from the JavaScript test runner around each test:
import { beforeEach, afterEach, expect } from "vitest";
import init, { live_bytes } from "../pkg/doc.js";
let before;
beforeEach(() => { before = live_bytes(); });
afterEach(() => {
const leaked = live_bytes() - before;
expect(leaked, `test leaked ${leaked} bytes in linear memory`).toBe(0);
});
test("document lifecycle frees everything", () => {
const doc = Document.parse(sample);
expect(doc.pageCount()).toBe(3);
doc.free(); // forgetting this now fails the test
});
Combine with the wrapper-count technique — counting created minus freed wrappers — to report leaked JavaScript objects by class.
Step 4 — handle memory that is meant to persist
Not every unbalanced allocation is a leak. Lazily initialised statics (once_cell, lazy_static, C++ function-local statics) allocate on first use and
never free; caches and interning tables grow by design; wasm-bindgen’s glue may allocate internal tables once. Warm these before measuring — call the
code paths once in a suite-level setup — so their allocations happen before the baseline. For caches, either provide a clear() the test calls, or
assert that a second run of the same test leaks nothing (a leak grows every run; a cache fills once). Document every exclusion, because an excluded
“cache” that grows without bound is a leak by another name.
Step 5 — run LeakSanitizer for C and C++
For Emscripten builds, LeakSanitizer reports every allocation still live at exit with a stack trace, which is more precise than balance checks:
emcc tests/*.cpp src/*.cpp -fsanitize=leak -g -o tests.js
node tests.js
# ==42==ERROR: LeakSanitizer: detected memory leaks
# Direct leak of 96 byte(s) in 1 object(s) allocated from:
# #0 malloc ... #1 Parser::parse_node(...) src/parser.cpp:211
Run it in a dedicated CI job; it slows execution and increases memory use. Details are in detecting leaks in Emscripten with LeakSanitizer.
Making failures actionable
A failing balance check says “this test leaked 1,344 bytes in 14 allocations”. To find where, re-run the single test with a recording allocator that
stores a backtrace or a caller tag for each live allocation and prints those still live at the end. In Rust, a debug-only allocator can capture
std::backtrace::Backtrace on native test runs; for Wasm runs, record the most recent “scope” set by the code. Often the native test run is the quickest
place to investigate, since the leak is usually in platform-independent logic and native tools (Valgrind, heaptrack) apply.
Keeping the suite fast
Counting allocators add little overhead; the cost is mainly serial execution. Leak-check a focused subset — tests that cover object lifecycles, parsing, error paths and boundary calls — rather than every test, and run the full leak-checked suite nightly. Error paths deserve special attention: early returns and exceptions are where cleanup is most often skipped, so every error-path test should also be a leak-checked test.
Leak checks for JavaScript-owned Wasm resources
Some resources tied to a module live on the JavaScript side but still hold Wasm memory: buffers allocated in linear memory and handed to JavaScript by pointer, callbacks registered with the module that keep Rust closures alive, and handles into object pools. A balance check on the allocator catches them only if the test releases them through the proper API. Write tests in the same shape users write code — create through the public wrapper, release through the public wrapper — rather than calling low-level exports directly, so the check covers the release path users depend on. For long-lived registrations such as event subscriptions, add an explicit “unsubscribe” step at the end of the test and assert that the module’s registry is empty; a registry that keeps one entry per test run is a leak that allocator counts reveal only slowly.
Turning a leak report into a regression test
Every leak found in production or in manual testing should become a leak-checked test: reproduce the sequence that leaked, wrap it in the balance check, confirm it fails, then fix the code and watch it pass. Over time, the suite becomes a catalogue of the lifecycle mistakes the codebase is prone to — often the same few patterns, such as early returns that skip cleanup or callbacks that capture objects — and new code touching those areas is protected automatically. Name these tests after the scenario rather than the bug ticket, so their purpose stays clear when the ticket is long closed.
Expected output
Leak-checked Rust tests and JavaScript tests run in CI; a test that forgets doc.free() fails with “test leaked 3,392 bytes in linear memory”; error-path
tests are leak-checked; lazy statics are warmed in setup; caches are cleared between tests; and a nightly LeakSanitizer job reports stacks for C++
dependencies.
Gotchas
- Lazy statics counted as leaks. Warm them before the baseline.
- Parallel tests sharing counters. Run leak-checked tests serially.
- Only checking Rust-side tests. Boundary leaks need JavaScript-side checks.
- Unbounded “caches” excluded from checks. Verify they stop growing on repeated runs.
- Skipping error paths. Cleanup is most often missed there.
- Testing through low-level exports. The public release path goes unchecked. Use the wrapper API in leak tests.
Performance note
The counting allocator added 3% to the Rust test suite’s run time; running the 140 leak-checked JavaScript tests serially added 11 s to CI, and the checks found six leaks in the first month, five of them on error paths.
Frequently Asked Questions
Can I check leaks in cargo test natively instead?
Yes, for module logic — and it is faster. Boundary leaks still need Wasm-side tests.
Does wasm-bindgen-test support per-test hooks?
Wrap test bodies in a helper function as shown; there is no global hook.
What about leaks in JavaScript objects? Use wrapper counts or heap snapshots; allocator counters only see linear memory.
Should balance be exact? Yes, after warming statics; tolerances hide small leaks that accumulate.
Should leak tests call low-level exports directly? No — create and release through the public wrapper, so the release path users depend on is the one being checked.
Related
- Counting allocations with a wrapping allocator — the counter.
- Detecting forgotten free calls in wasm-bindgen — wrapper leaks.
- Testing error paths across the boundary — where leaks hide.
- Unit testing Rust Wasm with wasm-bindgen-test — the test runner.
← Back to Memory Profiling & Leak Detection