Profiling Memory in Emscripten with the Memory Profiler

This page answers one task: a C or C++ application compiled with Emscripten uses more memory than expected, or its memory grows over time, and you want to see inside the heap — what is allocated, where from, and how fragmented it is — without leaving the browser.

Prerequisites

  • [ ] A C or C++ project built with Emscripten (emcc).
  • [ ] A development build configuration you can add flags to.
  • [ ] A scenario that reproduces the memory behaviour you want to understand.

What the memory profiler shows

Emscripten ships a simple memory profiler, enabled at link time with --memoryprofiler. It instruments the generated JavaScript to track allocations made through malloc, free and related functions, and draws an overlay on the page that updates while the application runs. The overlay shows the total heap size and how much of it is in use, a visual map of the heap with allocated and free regions, static data and stack usage, and — the most useful part — a list of the call sites responsible for the most live allocations, grouped by the JavaScript-side stack trace at the time of allocation.

It is a development tool: tracking every allocation and capturing stacks is slow, and the overlay is meant for a person looking at it. It complements, rather than replaces, LeakSanitizer (which reports unfreed allocations at exit with native-style stacks) and mallinfo (which gives numbers you can log or assert on).

What the Emscripten memory profiler overlay contains The overlay shows overall heap size and usage, a graphical map of the heap with allocated and free regions, static data and stack usage, and a table of allocation call sites ranked by live bytes, updated as the application runs. heap totals size, used, free heap map allocated vs free regions static + stack usage data and stack high-water allocation call sites ranked by live bytes update loop refreshes while running

Step 1 — build with the profiler

emcc src/*.cpp -O1 -g2 --memoryprofiler \
  -sALLOW_MEMORY_GROWTH=1 \
  -o app.html

-g2 keeps function names so stack traces in the overlay are readable. Use -O1 or -O2 rather than -O3 with heavy inlining, which merges call sites. Output to .html is simplest, because Emscripten’s default shell page has room for the overlay; with a custom shell, the overlay attaches to the page body.

Step 2 — run the scenario and watch the overlay

Open the page and run the scenario. The heap usage line shows whether memory plateaus or keeps rising. The heap map shows how allocations are distributed: a few large blocks, or a dense field of small ones. Watch it during the operation you care about — loading a level, opening a document, processing a batch — and again after it ends, when memory should be released.

Step 3 — find the call sites holding memory

The call-site table lists stacks with the number and total size of live allocations from each. Sort by size. A stack with many live allocations after an operation finished is a leak candidate or a cache that grows without bound. Because stacks are captured with the JavaScript Error().stack mechanism, they include the Wasm functions on the stack at allocation time, by name if -g2 was used. For more precise attribution — including inlined frames and source lines — reproduce the scenario in a native build or use LeakSanitizer.

From growing memory to a leaking call site Run the scenario with the profiler overlay. Observe heap usage rising across repetitions. Sort the call-site table by live bytes. Identify a stack that keeps accumulating after each repetition. Confirm with mallinfo or LeakSanitizer and fix the missing free or unbounded cache. run scenario --memoryprofiler build heap usage rises per repetition sort call sites by live bytes stack keeps growing leak or unbounded cache confirm + fix LSan / mallinfo

Step 4 — look for fragmentation

The heap map makes fragmentation visible: a heap that is mostly free but chopped into many small gaps between live blocks. Fragmentation forces the allocator to grow memory for large requests even when total free memory is ample, and because Wasm memory never shrinks, the growth persists. If the map shows this pattern, consider pooling objects of common sizes, allocating long-lived and short-lived objects from different arenas, or switching allocator (-sMALLOC=emmalloc is smaller but can fragment differently from dlmalloc; measure both).

Step 5 — confirm with numbers

The overlay is visual; decisions need numbers. Log mallinfo() totals at defined points — after startup, after each operation, after cleanup — and compare across repetitions:

#include <malloc.h>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE void log_heap(const char* label) {
  struct mallinfo mi = mallinfo();
  emscripten_log(EM_LOG_CONSOLE, "%s: in use %d bytes, free %d bytes, heap %d bytes",
                 label, mi.uordblks, mi.fordblks, mi.arena);
}

In-use bytes that return to the same value after each cycle mean no leak; in-use bytes that rise mean a leak or growing cache; a heap that grows while in-use bytes stay flat means fragmentation. Then confirm leaks with LeakSanitizer, which reports each leaked allocation with a stack at exit, as described in detecting leaks in Emscripten with LeakSanitizer.

Stack usage

The overlay also reports stack usage against the configured STACK_SIZE. A high-water mark close to the limit means deep recursion or large stack arrays are close to overflowing — which, depending on layout, either traps or corrupts data. Increase STACK_SIZE or reduce stack usage (move large arrays to the heap) before it becomes an intermittent crash. -sSTACK_OVERFLOW_CHECK=2 adds precise checking in debug builds.

Limits of the tool

The profiler tracks allocations through Emscripten’s malloc; memory allocated by other means — a custom allocator on top of a big malloc block, a Rust component with its own allocator, memory used directly by static data — appears as one allocation or not at all. Its overhead makes timing measurements meaningless while it is enabled, and it only works in the browser page it draws on, not in workers or Node. For workers, log mallinfo from the worker and inspect the numbers instead.

A repeatable profiling routine

Ad-hoc profiling finds the obvious problems; a routine finds the rest. Define a short script of actions that represents a typical session — start, load three documents, edit, close them, idle — and run it the same way each time you profile, ideally automated with a small JavaScript driver that calls into the application. Note heap totals and the top five call sites at fixed checkpoints in the script. Comparing those notes between releases shows whether a change increased memory use, and where. The automated driver can also run without the profiler, logging mallinfo at the same checkpoints, so the same routine produces both the investigative view and numbers suitable for CI.

Memory used by Emscripten’s runtime itself

Not all memory belongs to your code. Emscripten’s runtime allocates for file-system emulation (MEMFS keeps files in memory, so preloaded data and files written at runtime occupy the heap), for printf buffers, for pthread stacks in threaded builds, and for exception handling. The profiler attributes these allocations to runtime functions on the stack. Large MEMFS usage is common in ports of desktop applications that write temporary files; moving those to IDBFS or OPFS-backed storage, or avoiding the files altogether, can release substantial memory. Preloaded packages (--preload-file) are copied into MEMFS, so a 50 MB asset package costs 50 MB of heap for the whole session.

Sharing findings

The overlay is a visual tool, and screenshots are the easiest way to share what it shows, but they lose detail. When reporting a memory problem to a team, include the mallinfo numbers at each checkpoint, the top call sites with their sizes, the build flags used, and the scenario script, so others can reproduce the measurement exactly and verify a fix against the same baseline.

Expected output

The overlay shows heap usage rising by 2.1 MB per level load and not returning after unload; the call-site table attributes 1.9 MB of it to TextureCache::load; the fix bounds the cache; mallinfo in-use bytes return to 48 MB after each unload; and the heap map shows no persistent fragmentation after twenty cycles.

Gotchas

  • Profiling optimised builds with heavy inlining. Call sites merge. Use -O1/-O2 with -g2.
  • Trusting timings with the profiler on. It slows allocation. Measure performance separately.
  • Expecting it in workers. The overlay draws on the main page. Log mallinfo in workers.
  • Custom allocators on top of malloc. Show as single large blocks. Instrument them separately.
  • Ignoring stack usage. A near-full stack becomes an intermittent crash.
  • Forgetting runtime allocations. MEMFS files and preloaded packages live on the heap. Check runtime call sites too.

Performance note

With the profiler enabled, the level-loading scenario took 4.6 s instead of 1.2 s due to per-allocation tracking and stack capture — fine for investigation, unusable for performance work.

Level load time with and without the memory profiler Seconds to load a level in a development build without the memory profiler and with it enabled, showing the overhead of per-allocation tracking. seconds per level load profiler off 1.2 s --memoryprofiler 4.6 s

Frequently Asked Questions

Does --memoryprofiler work with Embind and C++ classes? Yes — it tracks the underlying malloc calls regardless of what made them.

Can I use it with MODULARIZE? Yes, though the overlay expects a page to draw on.

Does it show JavaScript heap usage? No — use DevTools heap snapshots for the JavaScript side.

Is it available for Rust? No; for Rust, use a counting allocator or allocator statistics.

Why does the heap include my asset files? Files preloaded with --preload-file or written at runtime live in MEMFS, which keeps them on the heap for the whole session.

← Back to Memory Profiling & Leak Detection