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 test on 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.

A leak-checked test Before the test, the wrapper warms lazy statics and records live bytes. The test body runs and frees what it created. After the test, the wrapper records live bytes again. If the difference is zero the test passes; otherwise it fails with the number of leaked bytes and allocations. warm lazy statics once per suite record live bytes before run test body create + free record live bytes after assert equal fail with the leak size

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.

What each check catches Allocator balance in Rust tests catches leaks inside the module's logic. Allocator balance checked from JavaScript tests also catches forgotten free calls on wrappers and glue leaks. Wrapper counts identify which class leaked. LeakSanitizer reports stacks for leaked allocations in C and C++. check catches reports Rust-side balance leaks in module logic bytes + allocation count JS-side balance forgotten free(), glue leaks bytes per test wrapper counts leaked JS wrapper objects class names LeakSanitizer (C/C++) unfreed malloc blocks allocation stacks

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.

Leaks found by test type in the first month Number of leaks found by leak-checked happy-path tests and by leak-checked error-path tests during the first month after enabling the checks. leaks found happy-path tests 1 leaks error-path tests 5 leaks

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.

← Back to Memory Profiling & Leak Detection