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.
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.
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_RUNTIMEis 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.
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.
Related
- Finding Wasm memory leaks in the browser — confirming a leak exists.
- Detecting forgotten free calls in wasm-bindgen — leaks on the JavaScript side.
- Testing Wasm modules with Vitest — a harness for leak checks.
- Mapping C error codes to JavaScript errors — freeing on every error path.
← Back to Memory Profiling & Leak Detection