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.

The cycle test Warm up once so the allocator reaches its working set, record the baseline, run the identical workload several times, and compare. A stable value means no leak; a value that rises in proportion to the number of cycles identifies both the leak and its per-cycle size. warm up one full cycle record baseline allocated_bytes() run the cycle × 10 identical input each time compare delta ÷ cycles = leak per cycle The warm-up is not optional: the first cycle populates caches and allocator free lists, so including it makes every run look like a leak. Dividing the delta by the cycle count gives the per-cycle size, which is often enough on its own to identify the structure being leaked. A leak of exactly 4096 bytes per cycle, for instance, is a strong hint about which buffer is involved.

Prerequisites

  • [ ] A workload you can run repeatedly with identical input
  • [ ] A build you can add an exported counter to
  • [ ] performance.memory or 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.

Two signals, two diagnoses When allocated bytes and reserved pages both climb, memory is being retained and never freed. When reserved climbs while allocated stays flat, the allocator is unable to reuse the freed space, which is fragmentation rather than a leak and has a different fix. both climbing → a leak something is allocated and never freed per-cycle size names the structure fix: find the missing free or drop reserved only → fragmentation freed space exists but cannot be reused mixed lifetimes and sizes cause it fix: arena per phase, or uniform sizes Without the allocated-bytes counter these two are indistinguishable, which is why so much leak hunting goes in circles.

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.

Bisecting the workload Running the cycle test with one step disabled at a time shows which step's absence makes the leak disappear. That is faster than reading code and works on a module you did not write. all steps leaks 480 KB per cycle without parse still leaks without transform clean — the transform step owns it Three runs of an existing test narrow a whole module to one function, before anyone opens the source.

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.

← Back to Memory Profiling & Leak Detection