Tracking Linear Memory Growth Over Time
This guide adds the small amount of instrumentation that turns a module’s memory from an invisible quantity into a monitored one: a sampled buffer size, a record of every growth event, and a high-water mark you can alert on before a user’s tab dies.
The motivation is that leaks and unbounded working sets rarely show up in development, where sessions are short and inputs are small. They show up in a support ticket six weeks later describing a tab that gets slow and then crashes. Two numbers reported from production make that a graph instead of a mystery.
Prerequisites
- [ ] Access to the instance and its exported
memory - [ ] Somewhere to send the numbers — a logging endpoint, or an existing metrics pipeline
- [ ] An idea of what normal looks like, from a development session
- [ ] Agreement on sampling frequency; every second is wasteful, every minute may be too coarse
Step-by-step procedure
1. Sample the buffer
const memory = instance.exports.memory;
let peak = memory.buffer.byteLength;
setInterval(() => {
const bytes = memory.buffer.byteLength;
if (bytes > peak) peak = bytes;
metrics.gauge("wasm.memory.bytes", bytes);
}, 30_000);
byteLength is a property read on an object you already hold — there is no measurable cost, and it is
exact rather than an estimate.
2. Log growth events
If the module imports its memory, the host can observe every growth by wrapping the object:
const memory = new WebAssembly.Memory({ initial: 64, maximum: 1024 });
const grow = memory.grow.bind(memory);
memory.grow = (pages) => {
const before = memory.buffer.byteLength;
const result = grow(pages);
console.info("wasm grow", { pages, before, after: memory.buffer.byteLength });
metrics.count("wasm.memory.grow", 1);
return result;
};
Growth is rare by nature, so this log stays small even in a long session — and each entry is a precise moment you can line up against what the user was doing.
3. Export a high-water mark from inside
For a module that manages its own allocations, the more useful number comes from the allocator:
#[no_mangle]
pub extern "C" fn peak_allocated_bytes() -> u32 { PEAK.load(Ordering::Relaxed) as u32 }
4. Attach the numbers to error reports
window.addEventListener("error", (e) => {
report(e, {
wasmBytes: memory.buffer.byteLength,
wasmPeak: peak,
wasmAllocated: instance.exports.allocated_bytes?.(),
});
});
A crash report that includes the memory footprint at the time is worth far more than one that does not, because out-of-memory failures often surface as an unrelated-looking error.
5. Alert on the trend, not the value
A module at 200 MB may be perfectly healthy if that is its working set. What warrants an alert is growth that does not level off — a session whose footprint at thirty minutes is double its footprint at ten.
Expected output
// a healthy session
00:00 wasm.memory.bytes 16 MB
00:30 wasm.memory.bytes 48 MB
01:00 wasm.memory.bytes 64 MB
02:00 wasm.memory.bytes 64 MB
10:00 wasm.memory.bytes 64 MB ← levelled off at its working set
// a leaking session
00:00 wasm.memory.bytes 16 MB
00:30 wasm.memory.bytes 48 MB
01:00 wasm.memory.bytes 96 MB
02:00 wasm.memory.bytes 190 MB
10:00 wasm.memory.bytes 940 MB ← still climbing linearly with time
The distinguishing feature is not the absolute number but the shape. A curve that flattens is a working set; a straight line is a leak, and its slope tells you roughly how long a session can last before the tab is in trouble.
Gotchas
Sampling too often. Every second gives you sixty times the data and no more insight, and on a large metrics pipeline the cost is real. Thirty seconds is plenty for a trend.
Wrapping grow on a memory the module owns. If the module declares its own memory rather than
importing one, the host never sees the growth call. Export a counter from inside instead.
Reading byteLength from a stale view. Read it from the Memory object, not from a cached typed
array — a detached view reports zero.
Confusing reserved with used. A module that reserved 256 MB at instantiation reports 256 MB immediately. That is a configuration choice, not consumption.
Metrics without a session identifier. A gauge aggregated across users hides the individual session that is climbing. Track per session and report the distribution.
Performance note
All three signals are essentially free. byteLength is a property read; the growth wrapper runs only
when growth happens, which is rare; the exported counter is a single atomic load. Sampling every thirty
seconds contributes nothing measurable to a page’s CPU or battery use, which is why this instrumentation
is reasonable to ship permanently rather than enable during an investigation.
What does cost something is the reporting pipeline, and that is a matter of volume rather than collection. Sending a gauge every thirty seconds per session is a lot of points across a large user base; sampling a fraction of sessions gives the same signal for a fraction of the cost, since a leak present for one user is present for all of them.
Alerting on the right thing
An alert on an absolute memory figure produces noise, because a legitimate working set varies enormously between users — someone editing a small document and someone editing a large one differ by an order of magnitude and both are healthy. The signal that generalises is the slope: memory that is still rising after the working set should have settled.
A workable rule computes growth between two sampling windows well after startup:
const samples = []; // {t, bytes} appended by the periodic sampler
function growthPerMinute() {
const recent = samples.filter((s) => s.t > performance.now() - 10 * 60_000);
if (recent.length < 4) return 0;
const first = recent[0], last = recent[recent.length - 1];
return (last.bytes - first.bytes) / ((last.t - first.t) / 60_000);
}
// report, do not alert per session — the distribution is the signal
metrics.gauge("wasm.memory.growth_per_min", growthPerMinute());
Aggregate that across sessions and alert on the distribution rather than on individuals. A handful of sessions with high growth is normal — someone is doing something unusual. A shift in the median after a release is a regression, and it is visible long before any user hits a limit.
Correlating growth with what the user did
A number that rises tells you there is a problem; a number that rises at a particular moment tells you where to look. Attaching a lightweight marker to significant user actions makes the correlation possible without a full session recording:
function recordAction(name) {
metrics.event("wasm.action", { name, bytes: memory.buffer.byteLength });
}
With that in place, a support report becomes tractable: the sequence of actions and the memory after each one usually identifies the operation responsible in minutes. Without it, reproducing the growth means guessing at what the user was doing, which is where most of the time in these investigations goes.
Keep the set of recorded actions small and stable. A dozen meaningful operations is useful; every click is noise, and the volume makes the data harder to read rather than richer.
Frequently Asked Questions
Can I shrink memory once it has grown?
No. linear memory is one-directional. The only way to return it is to discard the instance and create
a new one, which some applications do deliberately at natural boundaries.
Should I set a maximum on the memory?
Yes, if you have any idea of a reasonable ceiling. It converts a runaway allocation into a failed
grow that your code can handle, rather than a tab the browser kills.
Does this work for a module in a worker? Yes — the same instrumentation runs there, and reporting from a worker is often cleaner because the worker’s memory is the only significant consumer on that thread.
Should I sample from the main thread or from a worker? Whichever owns the instance. Memory belongs to the instance, so a module running in a worker has to be sampled there; sampling from the page would report nothing useful. Where several instances exist, report each separately with an identifier, because an aggregate figure hides the one that is growing.
What sampling interval is right? Thirty seconds is a good default for a long session — frequent enough to see a trend within minutes, infrequent enough that the volume is negligible. For a short-lived page, sampling on specific lifecycle events instead of on a timer gives a cleaner signal than any interval would.
Does reporting memory raise privacy questions?
deviceMemory is a coarse, already-exposed signal and buffer sizes say nothing about a user’s data, so
this is ordinarily unremarkable — but it is worth checking against your own telemetry policy before
adding fields, as with any new measurement.
Related
- Memory profiling & leak detection — the three regions and which tool applies.
- Finding Wasm memory leaks in the browser — turning a trend into a cause.
- Understanding Wasm linear memory limits — the ceiling this instrumentation watches.
- Why memory.grow invalidates pointers — what a growth event does to views.
← Back to Memory Profiling & Leak Detection