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,
mallinfoin 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.
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.
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.
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.
Related
- Counting allocations with a wrapping allocator — the statistics source.
- Tracking linear memory growth over time — longer-term tracking.
- Freeing Wasm objects with FinalizationRegistry — wrapper accounting.
- Measuring memory with measureUserAgentSpecificMemory — browser-level numbers.
← Back to Memory Profiling & Leak Detection