Memory Profiling & Leak Detection

A WebAssembly module’s memory is invisible to the tools most web developers reach for. The JavaScript heap snapshot shows a single ArrayBuffer of a few megabytes and nothing about what is inside it; the garbage collector has no view into that buffer at all. A leak in linear memory therefore produces a page whose memory grows steadily while every JavaScript-level tool reports that nothing is wrong.

This area is about making that memory legible: watching the buffer’s size over time, attributing growth to allocation sites, recognising the specific shape of a forgotten free, and telling a genuine leak apart from an allocator that has simply not returned pages to the system.

Prerequisites

  • [ ] A module you can rebuild with instrumentation — leak hunting from a stripped release build is hard
  • [ ] DevTools with the Memory and Performance panels
  • [ ] A reproduction: a sequence of actions that should return memory to where it started
  • [ ] Patience for the distinction between “allocated” and “reserved”, which is where most confusion lives

What a Wasm leak looks like

Two memory profiles over the same workload A healthy module allocates and frees within a stable envelope, so repeated cycles return to the same baseline. A leaking module's baseline rises with every cycle, and because linear memory never shrinks, the buffer grows monotonically. bytes time → healthy — returns to baseline leaking — baseline climbs each cycle Both lines show the same workload repeated. The diagnostic is not the peaks — it is whether the troughs return to where they started. Because linear memory cannot shrink, a climbing trough is permanent for the lifetime of the instance.

Two facts make Wasm leaks distinctive. The first is that linear memory never shrinks: memory.grow is one-directional, so once the module has asked for more pages, the browser holds them until the instance is discarded. That means transient peak usage becomes permanent footprint, and a single pathological input can raise a page’s memory for its whole session.

The second is that the leak may not be inside the module at all. A wasm-bindgen object whose free() was never called keeps its allocation alive; a JsValue held in a table slot keeps a JavaScript object alive. Those are leaks caused by the boundary rather than by the allocator, and they need different tools to find.

Step-by-step workflow

  1. Establish a baseline. Record memory.buffer.byteLength after startup and after the workload has run once — the second number is the honest starting point.

  2. Repeat the workload and watch the trough. Run the cycle five or ten times, sampling the length between cycles. A flat trough is healthy; a rising one is the signal.

  3. Distinguish reserved from allocated. Instrument the allocator, or export a function that reports how many bytes are currently handed out. Growth in reserved pages with stable allocated bytes is fragmentation, not a leak.

  4. Attribute the growth. Log allocation sites in a debug build, or bucket allocations by size to see which class is accumulating.

  5. Check the boundary separately. Count live wasm-bindgen objects, or instrument free() calls against constructions, to catch the ownership failures that look identical from outside.

  6. Fix, then re-run the same measurement. The trough returning to flat is the proof; anything else is a hypothesis.

Where the memory actually is

Three regions, three different leaks The JavaScript heap holds ordinary objects and is managed by the garbage collector. Linear memory holds the module's data and is managed by its allocator. The binding table holds references that pin JavaScript objects from the module side. A leak can live in any of them and looks the same from a page-level memory graph. JavaScript heap ordinary objects, closures, DOM garbage collected heap snapshot works here a retained listener is the classic leak linear memory the module's own allocations managed by its allocator, never shrinks one opaque ArrayBuffer to DevTools a missing free lives here the binding table JsValue slots the glue maintains pins JS objects from the Wasm side invisible to both other views a forgotten closure lives here Identify the region before choosing a tool: a heap snapshot will never find a leak in the middle column, however long you stare at it.

Tradeoffs in instrumentation

Every technique for making linear memory legible costs something. Wrapping the allocator to record sites adds code and slows every allocation, so it belongs in a debug build rather than production. Exporting a “bytes currently allocated” counter is cheap and worth keeping permanently — a single number that a monitoring system can watch is often enough to catch a regression before a user reports it.

The heavier tools — full allocation logs, per-site backtraces — are worth reaching for only once the cheap signals say there is something to find. Their cost is not only performance but noise: a log of every allocation in a busy module is large enough that finding the pattern in it becomes its own task.

How much instrumentation to leave in A buffer-length sample and an allocated-bytes counter are cheap enough to ship permanently. Size-bucketed allocation counts belong behind a feature flag. Per-site allocation logs are a temporary investigation tool and should not survive it. ship it — byteLength sample + allocated-bytes counter a property read and an atomic load; catches regressions before users report them feature-flag it — size-bucketed allocation counts one atomic increment per allocation; a few per cent on allocation-heavy code temporary only — per-site logs and backtraces distorts what it measures and produces more output than anyone will read

Gotchas & failure modes

Memory grows and then stabilises. That is not a leak; it is the allocator reaching its working set. Judge by the trough after repeated cycles, not by the first climb.

byteLength never decreases even after freeing. Correct and expected — linear memory cannot shrink. Free returns memory to the allocator, not to the browser.

A heap snapshot shows nothing unusual. The leak is inside the module’s buffer, which the snapshot treats as one opaque object. Instrument the module instead.

The leak disappears in a debug build. Different allocator behaviour, or the instrumentation changed the timing. Reproduce in a build as close to production as you can bear.

Memory grows only in one browser. Allocator and engine behaviour differ, particularly around when pages are returned. Compare against the module’s own allocated-bytes counter rather than the browser’s reported footprint.

Fragmentation, and why it is not a leak

The failure that most often gets misdiagnosed as a leak is fragmentation, and the two need different fixes, so distinguishing them early saves a great deal of time.

Fragmentation happens when freed space exists but cannot satisfy the next request because it is split into pieces that are individually too small. The allocator then grows memory even though the total amount of free space would have been sufficient. From outside, the symptom is identical to a leak: linear memory climbs and never comes back. From inside, the difference is stark — allocated bytes stay flat while reserved pages rise.

Three patterns produce it reliably. Mixing lifetimes is the most common: a long-lived structure allocated between two short-lived ones leaves a permanent obstacle when they are freed. Mixing sizes is the second: a stream of 40-byte and 4-megabyte allocations gives the allocator no way to reuse the small gaps. And repeated growth of a single collection is the third — each reallocation abandons the previous buffer, and if something else is allocated in between, the abandoned space is stranded.

The fixes follow from the causes rather than from any allocator setting. Separate the lifetimes: put transient data in an arena that resets at a natural boundary, and keep long-lived structures in the general allocator. Normalise the sizes: a pool of fixed-size blocks for a hot structure removes the problem entirely for that structure. And reserve capacity for collections whose final size is predictable, so they grow once rather than repeatedly.

What does not help is switching allocators in the hope that a smarter one will cope. A different allocator changes the constants, not the pattern; a program that interleaves lifetimes will fragment under any of them. The productive move is always to change the allocation pattern, which is why the size-bucket histogram is the most useful single piece of instrumentation available — it shows you the pattern rather than its consequences.

One consolation is that fragmentation is bounded in a way a leak is not. A fragmenting program reaches a plateau once the allocator has enough holes of enough sizes; a leaking program does not. If the memory graph flattens after a period of growth, you are probably looking at fragmentation and the question becomes whether the plateau is acceptable rather than whether something is broken.

Verification

The definitive check is a cycle test: run the workload N times and assert that allocated bytes return to their post-warmup baseline.

const bytes = () => instance.exports.allocated_bytes();
await runWorkload();                       // warm up allocator behaviour
const baseline = bytes();

for (let i = 0; i < 10; i++) await runWorkload();
const after = bytes();

console.log({ baseline, after, delta: after - baseline });
if (after - baseline > TOLERANCE) throw new Error("allocations are accumulating");

Running that in CI turns leak detection from an investigation into a regression test — which is the only way it stays fixed.

A worked investigation

The abstract workflow is easier to follow with a concrete case, so here is one that covers most of the techniques in order.

The report. An image editor becomes sluggish after a user has worked for twenty minutes, and eventually the tab is killed. Nothing in the JavaScript heap snapshot looks unusual; the retained size of the application’s own objects is stable across snapshots taken ten minutes apart.

Step one: is it the module? Log memory.buffer.byteLength every thirty seconds during a session that reproduces the problem. It climbs steadily from 64 MB to over 900 MB. That immediately narrows the search: the JavaScript heap is fine, so the growth is inside linear memory or in the binding table that pins objects from the module side.

Step two: allocated or merely reserved? Add a counting allocator and export the total. Allocated bytes track the buffer size closely, which rules out fragmentation — memory is genuinely being held, not merely unreturned.

Step three: how much per operation? Wrap the user action in a cycle test: warm up, record, repeat ten times, compare. The delta divided by the cycle count is 8.4 MB — suspiciously close to the size of one full-resolution frame at the editor’s canvas dimensions.

Step four: which operation? Disable steps in turn. With the preview render disabled the leak vanishes; with only the preview render enabled it persists at the same rate. That localises it to one function without reading its implementation.

Step five: the cause. The preview path allocates a scratch buffer per invocation and stores the pointer in a module-level variable for reuse, overwriting the previous pointer without freeing it. Every render leaks exactly one frame buffer. The fix is four lines: free the old buffer before replacing it, or better, allocate once and reuse.

Step six: prevent the recurrence. The cycle test from step three becomes a CI test asserting that allocated bytes return to baseline within a tolerance. The next person who introduces the same pattern finds out in the pull request rather than from a user.

Two things stand out about that sequence. Nobody read the module’s source until step five, and the investigation is entirely arithmetic until then — which is what makes it fast. And the per-cycle figure did most of the work: a number that matches a recognisable structure size is usually enough to guess the cause before proving it.

In this guide

Frequently Asked Questions

Can the browser’s memory tools see inside linear memory? Only as bytes. The Memory inspector will show you the contents at an offset, which is useful when you know where to look, but there is no structural view of the module’s heap the way there is for the JavaScript heap.

Does discarding the instance free the memory? Yes — dropping all references to an instance and its memory lets the garbage collector reclaim the whole buffer. For a page that can afford to re-instantiate, that is the bluntest and most reliable recovery from accumulated growth.

Is a growing memory always a problem? No. A module that legitimately holds more data over time — a cache, an editor document, a growing scene — is behaving correctly. The question is whether the growth corresponds to retained data you can point at.

Choosing an instrumentation strategy

The techniques in this area range from free to intrusive, and matching the level to the situation avoids both under-instrumenting a production problem and over-instrumenting a development one.

For a module you are actively developing, a counting allocator behind a feature flag is the right baseline. It costs an atomic increment per allocation, which is measurable only in allocation-heavy code, and it gives you the two numbers — allocated bytes and allocation count — that answer most questions immediately. Turning it on for a debugging session and off for a benchmark run is a one-word change to the build command.

For a module in production, the calculus reverses: nothing that affects the hot path is acceptable, and what you want is the smallest signal that would tell you something is wrong. A periodic sample of memory.buffer.byteLength and a high-water mark attached to error reports satisfy that completely, cost nothing measurable, and answer the question “is this getting worse” without any per-allocation work at all.

For a module you did not write, you have neither option, and the available signal is the buffer size across a repeated workload. That is enough to establish whether a leak exists and roughly how large it is per cycle, which is usually enough to file a useful bug report even without access to the source.

The mistake worth avoiding is reaching for the heaviest tool first. A full allocation log with call sites produces megabytes of output and distorts the timing of what it measures; it is the right tool for a leak that has resisted everything else, and the wrong first move for a leak that a ten-line cycle test would have localised in a minute.

One organisational note: whichever level you choose, put the numbers somewhere a graph can read them. A leak discovered by a user is expensive; the same leak visible as a rising line in a dashboard three weeks earlier is a routine fix. The instrumentation is cheap in every version described here — what makes it valuable is that somebody is looking at the output.

Is there a browser API that reports a module’s memory directly? Only memory.buffer.byteLength, which is the reserved size rather than the used size. There is no standard equivalent of a heap snapshot for linear memory, and there is unlikely to be one soon, because the browser genuinely does not know how the module has organised those bytes — that structure exists only in the compiled code. Anything more detailed has to come from instrumentation you add.

What about performance.measureUserAgentSpecificMemory? It reports the process-level breakdown, which includes Wasm memory as a category, and it is useful for answering “is this tab using too much memory overall”. It will not tell you which allocation is responsible, and it requires cross-origin isolation, so it complements the module-level instrumentation described here rather than replacing it. Where both are available, the browser API is a good cross-check that your own counters are measuring what you think they are.

How do I tell a leak from a cache that is doing its job? Ask whether the retained data corresponds to something the program could still use. A cache of decoded images that grows to a bounded size and then evicts is behaving correctly, even though memory rose and never came back down — linear memory cannot shrink, so a cache’s high-water mark is permanent footprint by design. A leak, by contrast, retains data nothing will ever read again, and the giveaway is that growth continues in proportion to the number of operations rather than levelling at a bound. If the program has no eviction policy at all, the distinction collapses: an unbounded cache and a leak are the same thing wearing different names, and both need a limit.

Should leak testing run on every commit or only before release? Every commit, if the cycle test is fast — and it usually is, because a leak that only appears after thousands of iterations is rare compared with one that appears after ten. The value of running it constantly is attribution: a failure on one pull request names the change responsible, while a failure found before a release names a fortnight of changes and starts a bisect.

← Back to JS/Wasm Interop & Memory Management