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.

Three signals worth collecting A periodic sample of buffer length shows the trend. A log of growth events shows exactly when and by how much the module asked for more. A high-water mark carried with error reports shows how close a session came to its limit. periodic sample memory.buffer.byteLength every 30 s, cheap and coarse gives you the trend line start here — it costs nothing growth events wrap the grow call, log pages + reason rare, so the log stays small tells you when and how much correlate with user actions high-water mark largest size this session reached attach it to error reports tells you how close to the edge one number, enormous diagnostic value All three are a few lines each, and together they answer "is this module's memory behaving" without any browser tooling at all.

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.

Shape matters more than magnitude A healthy module's memory rises quickly to its working set and then stays flat. A leaking module's memory keeps climbing at a roughly constant rate, and extrapolating that slope predicts when the session will fail. bytes session time → working set — flat after a few minutes leak — linear, and extrapolatable the point where the tab is at risk Alerting on the slope catches leaks weeks before alerting on an absolute threshold would.

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.

A maximum is a safety valve Without a declared maximum, a runaway allocation keeps growing until the browser terminates the tab, with no opportunity to react. With one, the growth call fails and the module can report an error the application handles. no maximum declared growth continues until the browser stops it the tab dies with no error you can catch maximum declared memory.grow returns −1 at the ceiling the module reports an error you can show Pick a ceiling generous enough for legitimate work and small enough to catch a runaway — an order of magnitude above the observed peak is a reasonable start.

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.

← Back to Memory Profiling & Leak Detection