Building an In-App Memory Panel for Wasm

This page answers one task: memory problems in a WebAssembly application are found late because nobody sees memory until something breaks. You want a small always-available debug panel — toggled by a key or a URL flag — that shows linear memory, live bytes, allocation rate and outstanding wrapper objects while developers and testers use the app.

Prerequisites

  • [ ] A Wasm module whose allocator can report statistics (a counting wrapper in Rust, mallinfo in C/C++).
  • [ ] Access to the application’s UI code.
  • [ ] A feature flag mechanism for debug tools.

What the panel should show

Four numbers explain most Wasm memory behaviour. Linear memory size (memory.buffer.byteLength) is what the browser has reserved for the module; it only grows. Live heap bytes — what the allocator currently has handed out — is what the program actually uses; the difference between the two is free space and fragmentation. Allocation rate (allocations or bytes per second) shows churn that costs time even without leaking. Outstanding wrappers — JavaScript objects owning Wasm memory that have not been freed — catches the most common JavaScript-side leak. Plotting each as a sparkline over the last minute makes trends visible at a glance: a sawtooth is healthy churn, a staircase is a leak.

The in-app memory panel The panel shows linear memory size, live heap bytes with a sparkline, the allocation rate per second, and the count of outstanding wrapper objects, sampled a few times per second from exported statistics and updated without disturbing the application. linear memory size memory.buffer.byteLength live heap bytes allocator statistics + sparkline allocation rate allocs/s and bytes/s outstanding wrappers created − freed sampling loop 4 Hz, requestAnimationFrame

Step 1 — export statistics from the module

In Rust, wrap the global allocator with counters and export a function that writes them into a small array JavaScript can read without allocating:

use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};

static LIVE: AtomicUsize = AtomicUsize::new(0);
static TOTAL_ALLOCS: AtomicUsize = AtomicUsize::new(0);

struct Stats;
unsafe impl std::alloc::GlobalAlloc for Stats {
    unsafe fn alloc(&self, l: std::alloc::Layout) -> *mut u8 {
        LIVE.fetch_add(l.size(), Relaxed); TOTAL_ALLOCS.fetch_add(1, Relaxed);
        std::alloc::System.alloc(l)
    }
    unsafe fn dealloc(&self, p: *mut u8, l: std::alloc::Layout) {
        LIVE.fetch_sub(l.size(), Relaxed);
        std::alloc::System.dealloc(p, l)
    }
}
#[cfg(feature = "mem-stats")]
#[global_allocator]
static A: Stats = Stats;

static mut STATS_OUT: [u32; 2] = [0; 2];

#[no_mangle]
pub extern "C" fn mem_stats() -> *const u32 {
    unsafe {
        STATS_OUT = [LIVE.load(Relaxed) as u32, TOTAL_ALLOCS.load(Relaxed) as u32];
        core::ptr::addr_of!(STATS_OUT) as *const u32
    }
}

For C and C++, mallinfo() provides uordblks (in-use bytes) and arena (heap size); count allocations with a thin wrapper around malloc if you need rates. Gate the statistics behind a feature so release builds pay nothing.

Step 2 — count wrapper objects in JavaScript

wasm-bindgen does not expose a count of live wrappers, but your wrapper layer can. Count creations and free() calls for exported classes, or register each wrapper with a FinalizationRegistry to count objects that were collected without being freed:

export const wrapperStats = { created: 0, freed: 0, collectedUnfreed: 0 };
const registry = new FinalizationRegistry(() => wrapperStats.collectedUnfreed++);

export function track(obj) {
  wrapperStats.created++;
  registry.register(obj, null, obj);
  const free = obj.free.bind(obj);
  obj.free = () => { wrapperStats.freed++; registry.unregister(obj); free(); };
  return obj;
}

created − freed − collectedUnfreed is the number of wrappers currently alive and unfreed; a steadily rising number is a leak.

Step 3 — sample cheaply

Sample a few times per second, not every frame, and avoid allocating while sampling — the panel must not cause the churn it measures:

const stats = new Uint32Array(2);
let prevAllocs = 0, prevT = performance.now();

function sample() {
  const ptr = wasm.mem_stats();
  stats.set(new Uint32Array(wasm.memory.buffer, ptr, 2));
  const t = performance.now();
  const rate = ((stats[1] - prevAllocs) * 1000) / (t - prevT);
  prevAllocs = stats[1]; prevT = t;
  return {
    memoryMB: wasm.memory.buffer.byteLength / 2 ** 20,
    liveMB: stats[0] / 2 ** 20,
    allocsPerSec: rate,
    wrappers: wrapperStats.created - wrapperStats.freed - wrapperStats.collectedUnfreed,
  };
}
setInterval(() => panel.update(sample()), 250);

Creating a Uint32Array view per sample allocates a small object; for zero-allocation sampling, keep a view and recreate it only when memory.buffer changes identity after growth.

One sampling cycle of the memory panel Every 250 milliseconds the panel calls the module's stats export, reads live bytes and allocation count through a typed-array view, computes the allocation rate from the previous sample, adds the wrapper count from JavaScript, and redraws numbers and sparklines in a fixed canvas. timer 250 ms not every frame wasm.mem_stats() pointer to counters read view live bytes, allocs compute rates vs previous sample redraw panel fixed-size canvas

Step 4 — draw a lightweight panel

Draw into a fixed-size <canvas> positioned over the app, so updates do not trigger layout of the page. Show current values as text and the last 240 samples as sparklines. Colour live bytes against linear memory size, so the gap — free and fragmented space — is visible. Keep the panel’s own code small and its allocations bounded: a ring buffer of samples, preallocated.

Step 5 — ship it safely

Enable the panel with a URL parameter or keyboard shortcut in development and staging builds. In production, either exclude it entirely, or include it behind a flag for support sessions — but compile the statistics feature into the module only where it is enabled, since counting allocations costs a little on every allocation. Never send the numbers anywhere automatically from the debug panel; production telemetry belongs in the observability pipeline, with sampling and consent.

Reading the panel

A few patterns come up repeatedly. Live bytes rising in steps with each user action and never falling is a leak; check outstanding wrappers first, then allocator call sites. Linear memory far above live bytes after a burst is fragmentation or a released working set that cannot be returned; it is normal after peaks, but if it happens repeatedly the peak itself may be avoidable. A high allocation rate during idle means something is allocating per frame when nothing changes — often formatting or temporary collections in a render loop. Wrapper counts rising while live bytes stay flat suggests wrappers of small objects, which are cheap individually but still a leak.

Making the panel useful for testers

Testers find memory problems when they can see them. Add a “mark” button that records the current values with a label, and a “copy report” button that puts the marks and recent samples on the clipboard as text. Bug reports with “after opening the 20th document, live memory was 410 MB and wrappers were 1,980” are far more actionable than “the app got slow”.

Per-feature attribution

Totals tell you that memory grew; attribution tells you which part of the application grew it. A simple, cheap form of attribution is a scope counter: the allocator wrapper keeps a current-scope index in a global, and live bytes are accumulated per scope. The application sets the scope when entering a feature — set_mem_scope(SCOPE_IMPORT) before an import, SCOPE_RENDER in the render loop — and the panel shows a row per scope. Because deallocation must subtract from the scope that allocated, store the scope in a small header before each allocation, or keep a side table keyed by address in debug builds. The overhead is acceptable in development and makes questions like “how much memory does the PDF importer hold after it finishes?” answerable at a glance, without a profiler session.

Thresholds and warnings

Developers rarely watch a panel continuously, so let it raise its hand. Configure thresholds — live bytes above a budget, more than a set number of outstanding wrappers, allocation rate above zero while idle for ten seconds — and turn the panel’s border red or log a console warning with the current values when one is crossed. Thresholds drawn from the product’s memory budget (for example, what fits comfortably on a mid-range phone) turn the panel from a curiosity into an early-warning system during everyday development and manual testing, so problems are noticed on the day they are introduced rather than when users report crashes.

Expected output

Pressing the debug shortcut shows a panel with linear memory 256 MB, live heap 118 MB, 3,400 allocations per second during scrolling and 0 when idle, and 42 outstanding wrappers that return to 12 after closing documents; a tester’s copied report reveals a leak of one Page wrapper per print preview.

Gotchas

  • Sampling every frame. Adds overhead and noise. Sample a few times per second.
  • Allocating in the sampler. It measures itself. Preallocate.
  • Statistics in release builds by default. Counting costs on every allocation. Gate it.
  • Confusing memory size with usage. Memory only grows. Show live bytes alongside.
  • No way to share readings. Add marks and a copy button.

Performance note

The counting allocator added about 2% to an allocation-heavy benchmark; sampling at 4 Hz and drawing the panel cost under 0.1 ms per sample.

Overhead of the memory panel Percentage slowdown of an allocation-heavy benchmark with the counting allocator enabled, and the per-sample cost of reading statistics and drawing the panel. overhead (%) counting allocator 2 % sampling + drawing at 4 Hz 0.1 %

Frequently Asked Questions

Can the panel show JavaScript heap usage too? performance.memory (Chromium only) or measureUserAgentSpecificMemory give coarse numbers; add them if useful.

Does it work in workers? Collect stats in the worker and post them to the main thread at the same rate.

Should production builds include it? Only behind a flag, with the statistics feature compiled in deliberately.

What about multiple modules? Show one row per module; each has its own memory and allocator.

Can the panel show which feature holds memory? Yes, with scope counters in the allocator wrapper that the application sets when entering each feature.

How should thresholds be chosen? From the product’s memory budget for target devices — for example, what a mid-range phone handles comfortably — rather than from current usage.

← Back to Memory Profiling & Leak Detection