Detecting Leaks in Emscripten with LeakSanitizer

This page answers one task: a C or C++ module compiled with Emscripten leaks memory — linear memory grows across repeated operations — and you need the exact allocation sites of the leaked blocks rather than a guess.

Prerequisites

  • [ ] Emscripten (emsdk) with a recent version; LeakSanitizer support is built in.
  • [ ] A way to run the module’s leaking operation repeatedly, ideally as a Node test.
  • [ ] Debug information in the build (-g) for readable stack traces.

How LeakSanitizer works

LeakSanitizer (LSan) is the leak-detection part of the sanitizer family. In a sanitized build, every allocation is recorded together with the stack trace that made it. When a check runs — by default at program exit — LSan scans the program’s reachable memory: globals, the stack, and every block reachable from them through anything that looks like a pointer. Allocated blocks that cannot be reached are reported as leaks, each with the stack trace of its allocation.

Emscripten supports LSan both standalone (-fsanitize=leak) and as part of AddressSanitizer (-fsanitize=address, which includes leak checking). The standalone version is much cheaper — it only tracks allocations, without instrumenting every memory access — so it is the right tool when the problem is leaks rather than corruption.

From a leaking module to a leak report A build with -fsanitize=leak records each allocation with its stack. The test runs the leaking operation. At the leak check, LSan scans reachable memory from globals and the stack, and reports every unreachable block with the stack trace that allocated it. build emcc -fsanitize=leak -g run workload allocations recorded leak check at exit or on demand scan reachable memory globals, stack, pointers report unreachable blocks + stacks

Step 1 — build with the sanitizer

emcc src/*.c -o build/lib.mjs \
  -fsanitize=leak -g -O1 \
  -sALLOW_MEMORY_GROWTH=1 -sEXIT_RUNTIME=1 \
  -sMODULARIZE=1 -sEXPORT_ES6=1 \
  -sEXPORTED_FUNCTIONS=_parse,_free_doc,_main

-g gives function names and line numbers in the report; -O1 keeps the build reasonably fast while preserving stacks. EXIT_RUNTIME=1 matters: by default Emscripten keeps the runtime alive after main returns, so the exit-time leak check never runs. With it, returning from main or calling exit() triggers the check.

Step 2 — run the leaking operation and exit

Drive the suspicious operation from main or from a small harness, then exit:

int main(void) {
    for (int i = 0; i < 100; i++) {
        Doc *d = parse(SAMPLE, sizeof SAMPLE - 1);
        free_doc(d);
    }
    return 0;                            /* LSan runs here */
}
node build/run.mjs

Repeating the operation makes per-call leaks obvious: a leak that occurs on every call shows up with a count of 100 allocations, distinguishing it from one-off allocations at startup.

Step 3 — read the report

==42==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 6400 byte(s) in 100 object(s) allocated from:
    #0 0x1a2b in malloc+0x1a2b (lib.wasm+0x1a2b)
    #1 0x4f10 in parse_attributes src/parse.c:88:23
    #2 0x51c2 in parse_element src/parse.c:141:9
    #3 0x5a07 in parse src/parse.c:212:12
    #4 0x6110 in main src/main.c:5:19

Indirect leak of 1800 byte(s) in 300 object(s) allocated from:
    #0 0x1a2b in malloc+0x1a2b (lib.wasm+0x1a2b)
    #1 0x3e44 in strdup_n src/util.c:17:15
    #2 0x4f52 in parse_attributes src/parse.c:93:21

SUMMARY: LeakSanitizer: 8200 byte(s) leaked in 400 allocation(s).

Direct leaks are blocks with no pointer to them anywhere; indirect leaks are blocks reachable only from leaked blocks. Fix direct leaks first: here, the attribute array allocated at parse.c:88 is never freed by free_doc, and the 300 attribute strings it points to are leaked indirectly as a consequence. Fixing the one missing free usually clears both.

Reading direct and indirect leaks A direct leak is a block nothing points to; fix the missing free at its allocation site. An indirect leak is reachable only from a leaked block; it usually disappears when the direct leak is fixed. Leaks from startup code that live for the whole program can be suppressed. LeakSanitizer reported a block direct leak no pointer anywhere — add the missing free indirect leak owned by a leaked block — fix the direct one first intentional global lives until exit — suppress it

Step 4 — check for leaks without exiting

In a browser or a long-running test, exiting is not always practical. Call the check explicitly at a quiescent point:

#include <sanitizer/lsan_interface.h>

EMSCRIPTEN_KEEPALIVE int check_leaks(void) {
    return __lsan_do_recoverable_leak_check();    /* prints a report, returns non-zero if leaks */
}

From JavaScript, run the workload, release everything the module should have freed, and call Module._check_leaks(). The recoverable version can be called repeatedly — after each test case, for instance — and lets the test fail with the report in its output. Note that blocks still referenced from JavaScript-held pointers are invisible to LSan’s scan, which looks only at Wasm memory and the Wasm stack; free them, or the report will list them as leaks.

A useful pattern for browser tests is a three-phase check: run the workload once to warm up caches and lazy globals, call the check to establish that the warm state is clean, then run the workload many more times and check again. Any block reported by the second check was allocated by the repeated workload and never freed — a true per-operation leak, free of noise from one-time initialisation. The same idea works with plain heap statistics when the sanitizer build is unavailable: compare in-use bytes after the warm-up with in-use bytes after a hundred more iterations, and treat steady growth as a leak to investigate with LSan.

Step 5 — suppress known, intentional leaks

Some allocations are meant to live until exit: a global cache, a table built at startup. Reporting them every run buries real leaks. Suppress them by function name with a suppressions file or the default-suppression hook:

const char *__lsan_default_suppressions(void) {
    return "leak:init_global_tables\nleak:third_party_logger_init\n";
}

Keep the list short and specific; every suppression is a place a real leak could hide. Prefer freeing intentional globals in a cleanup function called before exit in test builds, which keeps the report empty without suppressions.

Leaks that LSan cannot see

LeakSanitizer finds blocks nobody points to. It does not find memory that is still reachable but no longer needed — a cache that grows without bound, a list of event records that is appended to forever, a global map whose entries are never removed. From LSan’s point of view these are live objects, and the report stays clean while memory climbs. The technique for those is different: compare heap snapshots or allocation counts between two points in a repeated workload, and look for sizes or call sites whose live count grows with each repetition. A counting allocator, or the statistics from mallinfo(), give the totals; Emscripten’s --memoryprofiler option adds an in-page overlay that shows allocations by call site as the program runs. Leaks on the JavaScript side — wrapper objects that hold Wasm pointers and are never freed — are also invisible to LSan, because the pointers live in JavaScript, and need the techniques described in detecting forgotten free calls in wasm-bindgen.

Making leak checks part of the test suite

A leak found once tends to return. Build a sanitizer variant of the module in CI — same sources, -fsanitize=leak, a test harness that runs each test case and then calls the recoverable check — and fail the job when any leak is reported. Because standalone LSan is cheap, the sanitized test run is only modestly slower than the normal one, so it can run on every change rather than nightly. Keep the release build entirely separate: sanitizer runtimes add size and change allocator behaviour, and must never ship. A one-line matrix entry in the CI configuration — build: [release, lsan] — keeps both builds in step with no extra maintenance. For the wider set of sanitizers and when each applies, see catching memory bugs with Emscripten sanitizers.

Expected output

After adding the missing free(doc->attrs) in free_doc, rerunning the harness prints no LeakSanitizer report and exits with status 0, and linear memory in the browser stays flat across 10,000 parse-and-free cycles.

Gotchas

  • No report at all. EXIT_RUNTIME is off, so exit-time checks never run. Enable it or call the check explicitly.
  • Unreadable stacks. Missing -g. Rebuild with debug info.
  • Pointers held by JavaScript reported as leaks. LSan cannot see JS references. Free them before checking.
  • Suppressing too broadly. A suppression on a common function hides real leaks. Suppress narrowly.
  • Counting startup allocations as leaks. One-time globals appear in exit reports. Warm up first, then check twice.
  • Shipping a sanitized build. It is larger and slower. Keep it to tests.

Performance note

The standalone LeakSanitizer build ran the parser test suite in 1.6× the time of a normal -O1 build; the full AddressSanitizer build took 3.1×. Module size grew from 180 KB to 310 KB with LSan and 690 KB with ASan.

Test-suite run time with sanitizer builds Relative run time of the same test suite with a normal -O1 build, a LeakSanitizer build and an AddressSanitizer build. run time relative to a normal build normal -O1 build 1 × -fsanitize=leak 1.6 × -fsanitize=address 3.1 ×

Frequently Asked Questions

Can LSan detect leaks in Rust code? Not through Emscripten’s runtime; for Rust use a counting allocator, as in counting allocations with a wrapping allocator.

Does LSan work in the browser? Yes; the report goes to the console through Emscripten’s stderr.

Why does LSan miss some leaks? A stale value that happens to look like a pointer keeps a block “reachable”. Such false negatives are rare but possible.

Should I use ASan or LSan? Use LSan for leaks alone; use ASan when you also suspect out-of-bounds or use-after-free bugs.

Does LSan slow allocation? Yes — each allocation records a stack trace. Allocation-heavy code runs noticeably slower, which is why it belongs in tests, not production.

← Back to Memory Profiling & Leak Detection