Finding Wasm Memory Leaks in the Browser
This guide takes a page whose memory climbs and narrows the cause to a specific allocation — using a repeatable cycle test, a counter the module exports, and bisection over the workload rather than guesswork over the code.
The workflow matters because the usual browser tools do not apply. A heap snapshot sees the module’s
whole linear memory as one ArrayBuffer and tells you nothing about its contents, and the garbage
collector has no involvement at all. Everything useful comes from instrumenting the module and being
disciplined about the measurement.
Prerequisites
- [ ] A workload you can run repeatedly with identical input
- [ ] A build you can add an exported counter to
- [ ]
performance.memoryor the Memory panel for a coarse cross-check - [ ] A tolerance value — allocators are not perfectly deterministic
Step-by-step procedure
1. Export an allocated-bytes counter
use std::sync::atomic::{AtomicUsize, Ordering};
static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
pub struct Counting;
unsafe impl std::alloc::GlobalAlloc for Counting {
unsafe fn alloc(&self, l: std::alloc::Layout) -> *mut u8 {
ALLOCATED.fetch_add(l.size(), Ordering::Relaxed);
std::alloc::System.alloc(l)
}
unsafe fn dealloc(&self, p: *mut u8, l: std::alloc::Layout) {
ALLOCATED.fetch_sub(l.size(), Ordering::Relaxed);
std::alloc::System.dealloc(p, l)
}
}
#[global_allocator]
static A: Counting = Counting;
#[no_mangle]
pub extern "C" fn allocated_bytes() -> u32 {
ALLOCATED.load(Ordering::Relaxed) as u32
}
This is the single most useful piece of instrumentation available, because it separates allocated from reserved — the distinction that most leak investigations founder on.
2. Write the cycle test
const bytes = () => instance.exports.allocated_bytes();
await runWorkload(); // warm up
const baseline = bytes();
const CYCLES = 10;
for (let i = 0; i < CYCLES; i++) await runWorkload();
const delta = bytes() - baseline;
console.log({ baseline, delta, perCycle: delta / CYCLES });
3. Interpret the result
A per-cycle value near zero means no leak. A consistent positive value is a leak, and its size is a strong clue: a few dozen bytes suggests a small structure per iteration, a few kilobytes suggests a buffer, and a value that matches a known allocation exactly identifies it outright.
4. Bisect the workload
async function runWorkload() {
await stepParse(); // comment out one at a time
await stepTransform();
await stepEncode();
}
Disable steps until the leak disappears. This is faster than reading code, and it works even in a module you did not write.
5. Cross-check against the buffer size
console.log({
allocated: instance.exports.allocated_bytes(),
reserved: instance.exports.memory.buffer.byteLength,
});
Reserved climbing while allocated stays flat is fragmentation. Both climbing together is a leak.
6. Fix and re-run the same test
Expected output
// leaking
{ baseline: 2117632, delta: 4915200, perCycle: 491520 }
{ allocated: 7032832, reserved: 8388608 }
// after the fix
{ baseline: 2117632, delta: 8192, perCycle: 819 }
{ allocated: 2125824, reserved: 8388608 }
The per-cycle figure of 491,520 bytes is 480 KB — close enough to a 640×480 frame buffer of four-byte pixels to name the culprit before opening the code. That kind of arithmetic is why the counter is worth more than any general-purpose tool here.
Note also that reserved memory did not fall after the fix. It cannot: linear memory never shrinks, so
the buffer stays at its high-water mark for the life of the instance. What changed is that it stopped
climbing.
Gotchas
The first cycle always looks like a leak. Allocator warm-up. Discard it, always.
The counter itself is wrong. A custom allocator that misses a path — a bulk realloc, a
zero-size allocation — reports drift that is not real. Sanity-check it by allocating and freeing a known
amount.
The leak only appears with real data. Synthetic input often takes a different code path. Reproduce with a captured real input before concluding the module is clean.
Memory climbs but the counter is flat. The leak is on the JavaScript side of the boundary — a
retained wasm-bindgen object or a table slot. See
detecting forgotten free calls.
The fix works locally and not in production. Different allocator or a different build profile. Test the release artifact, not a debug one.
Performance note
The counting allocator adds an atomic add and subtract to every allocation. In a module doing heavy allocation that is measurable — a few per cent — which is why it belongs behind a feature flag rather than in the shipped build. The exported counter itself costs nothing when not called.
Once a leak is found and fixed, keeping the cycle test in CI is the cheap part: it runs the workload a dozen times and compares two numbers, which for most modules is a second or two. That is a much better trade than rediscovering the same leak after the next refactor.
Making the test part of the suite
Once a leak is found, the cycle test that found it is the cheapest possible guard against its return. Turning it into a permanent test takes a few lines and runs in seconds:
import test from "node:test";
import assert from "node:assert/strict";
test("workload does not accumulate allocations", async () => {
const { instance } = await loadModule();
const bytes = () => instance.exports.allocated_bytes();
await runWorkload(instance); // warm up: allocator reaches its working set
const baseline = bytes();
const CYCLES = 20;
for (let i = 0; i < CYCLES; i++) await runWorkload(instance);
const perCycle = (bytes() - baseline) / CYCLES;
assert.ok(perCycle < 1024, `leaking ~${Math.round(perCycle)} bytes per cycle`);
});
The tolerance matters. Zero is too strict — allocators legitimately retain small amounts as their free lists settle — and a megabyte is too loose to catch anything. A kilobyte per cycle is a reasonable default: small enough that a real leak of any interesting structure trips it, large enough that allocator noise does not.
Run it against the release build, not a debug one, since allocator behaviour differs between profiles and it is the release artifact that ships.
Leaks that are not in the module
If the counter stays flat while the buffer grows, or while the page’s overall memory grows, the retention is on the JavaScript side of the boundary and needs different tools. Three causes account for almost all of it.
A wasm-bindgen object dropped without free() holds its allocation; that shows in the module’s
allocated bytes, so it is not this case. A JsValue or a Closure held in the glue’s table pins a
JavaScript object, which shows in a heap snapshot as a retained object with no obvious referrer — the
binding table is the referrer. And an event listener registered from the module and never removed keeps
both the closure and everything it captures alive.
For all three the heap snapshot is the right tool, because the retained objects are ordinary JavaScript values. What makes them confusing is the retainer path, which terminates in the glue’s internal table rather than in application code — so the search is for what registered them, not for what still points at them.
Frequently Asked Questions
Can I find a leak without modifying the module?
Partly. Watching memory.buffer.byteLength across cycles tells you whether the buffer is growing, which
is enough to confirm a problem exists. Attributing it usually needs instrumentation.
Does dropping the instance help? It reclaims everything, which is a legitimate mitigation for a long-lived page with a bounded workload — re-instantiate periodically rather than leaking indefinitely. It is a workaround, not a fix.
Is a leak in Wasm a security problem? Not directly: the sandbox contains it, and the module cannot read outside its own memory. It is an availability problem — a tab that grows until the browser kills it.
How long should the cycle test run? Long enough that a small per-cycle leak becomes visible above allocator noise — usually ten to twenty cycles. A leak of a few dozen bytes per cycle needs more repetitions to distinguish from settling behaviour than one of several megabytes, so start with twenty and reduce only if the test becomes slow enough to be skipped.
Can I run the cycle test against a production build? Yes, provided it exports the allocated-bytes counter. Keeping that one export in the shipped artifact costs a few dozen bytes and makes the same test runnable against exactly the binary users receive, which removes the “it only leaks in release” class of investigation entirely.
Does the counting allocator work for C and C++ modules?
Yes, with a wrapper around malloc and free rather than a Rust global allocator. The analysis is
identical, and the export that reports the total is the same idea — a function returning a counter the
host can read between cycles.
Does the tolerance need tuning per module? Usually once, based on the noise you observe over a few runs of a known-good build.
Related
- Memory profiling & leak detection — the surrounding model and the three memory regions.
- Tracking linear memory growth over time — watching for this in production.
- Linear memory management & allocators — the allocator behaviour behind fragmentation.
- Implementing a bump allocator in Wasm — an allocator design that cannot fragment.
← Back to Memory Profiling & Leak Detection