Debugging Emscripten Builds with Assertions
This page answers one task: an Emscripten build fails at runtime with a terse Aborted(), RuntimeError: unreachable or silent misbehaviour, and you
want a debug configuration that turns those failures into specific, actionable messages.
Prerequisites
- [ ] Emscripten (emsdk) and a C/C++ project that builds.
- [ ] A reproducing input or test.
- [ ] Separate debug and release build configurations, or the willingness to add one.
What assertions catch
Release builds remove runtime checks for size and speed. When something goes wrong, the program traps or aborts with little information: the C runtime
called abort() for some reason; a function pointer was called with the wrong signature; the stack overflowed into static data; a function that was not
exported was called from JavaScript. Emscripten’s debug settings add checks that catch these at the moment they happen and print what went wrong.
-sASSERTIONS=1 (enabled automatically at -O0) adds checks in the JavaScript glue: calls to functions that were not exported, use of runtime methods
that were not included, wrong argument counts, memory-growth problems, and readable messages for aborts. -sASSERTIONS=2 adds more expensive checks.
-sSTACK_OVERFLOW_CHECK=1 or =2 detects shadow-stack overflow. -sSAFE_HEAP=1 instruments memory accesses to catch null pointers, unaligned access and
out-of-range addresses. Sanitizers go further, as described in
catching memory bugs with Emscripten sanitizers.
Step 1 — create a debug build profile
Keep a debug configuration that turns on the cheap checks permanently, so developers and CI use it by default:
emcc src/*.c -O1 -g \
-sASSERTIONS=2 -sSTACK_OVERFLOW_CHECK=2 -sSAFE_HEAP=1 \
-sALLOW_MEMORY_GROWTH -o build/debug/app.js
-g keeps debug information so stack traces and DevTools show source files and lines. -O1 keeps builds reasonably fast while preserving
debuggability; -O0 is slower to run and occasionally hides optimisation-dependent bugs. In CMake, put these in the Debug build type’s flags.
Step 2 — read the improved messages
With assertions on, an abort explains itself. Instead of Aborted(), you see messages like:
Aborted(Assertion failed: missing function: png_read_info)
Aborted(native function `compute` called before runtime initialization)
Aborted(stack overflow (Attempt to set SP to 0x0000eff0, with stack limits [0x00010000 - 0x00020000]))
Aborted(segmentation fault storing 4 bytes at address 0)
Each points at a category of bug: a missing symbol or export, a call before the module finished initialising (await the factory promise), a stack too
small for the code’s frames, a null-pointer write. The stack trace that follows, with -g, shows the source line.
Step 3 — catch stack overflows
The shadow stack lives below static data in linear memory, and overflowing it silently corrupts globals in release builds — one of the hardest bugs to
diagnose. -sSTACK_OVERFLOW_CHECK=2 checks the stack pointer on every function entry and aborts with the message above. If it fires, raise -sSTACK_SIZE
(default 64 KiB) or move large local arrays to the heap. The difference between this stack and the engine’s call stack is explained in
understanding the shadow stack in linear memory.
Step 4 — see what the linker did
Many problems are decided at link time: which functions were exported, which system libraries were linked, which settings took effect. Ask Emscripten to show its work:
emcc ... -v # print the clang and wasm-ld commands it runs
EMCC_DEBUG=1 emcc ... # keep intermediate files and detailed logs in /tmp/emscripten_temp
emcc ... -sVERBOSE=1 # extra information from Emscripten's own steps
-v shows the exact wasm-ld invocation, including --export flags, which explains “function not exported” errors. EMCC_DEBUG=1 keeps the pre-optimisation
Wasm and JavaScript so you can compare what changed. For symbol-level questions, --print-map from the linker, as in
reading wasm-ld map files,
shows where each function came from.
Step 5 — make debug builds part of testing
Run the test suite against the debug build in CI, not just locally. Assertions catch mistakes that pass in release builds by luck — a read from a null
pointer that returns zero, a stack overflow into unused globals — and turn them into failing tests. Keep a separate sanitizer job for deeper checks, and
the release build’s tests for performance and size. Never ship debug builds: SAFE_HEAP alone can slow programs several times, and assertion messages
expose internal details.
Debugging initialisation-order problems
A frequent class of bug in Emscripten integrations is calling into the module before it is ready. With MODULARIZE, the factory returns a Promise; calls
made before it resolves fail. Without it, the module calls Module.onRuntimeInitialized when ready. Assertions report these as “called before runtime
initialization”. Other initialisation issues include global constructors that throw or abort, file-system preloading that has not finished, and
main running automatically when the code expected to call exports first (use -sINVOKE_RUN=0 to prevent main from running). When the failure
happens only sometimes, it is usually a race between page code and module initialisation; await the factory everywhere and remove timing-dependent code.
Debugging in the browser with DWARF
Assertions explain what went wrong; DevTools shows where and with which values. With -g and the C/C++ DevTools Support (DWARF) extension in
Chrome, you can set breakpoints in C source, inspect variables with their C types, and step through code. Combine the two: let assertions abort at the
first error, open DevTools with “Pause on exceptions” enabled, and inspect the state at that point. Firefox supports source-level debugging for
Wasm with source maps (-gsource-map). The workflow is described in
debugging Wasm with DWARF source maps.
Function-pointer and signature errors
C code that casts function pointers between incompatible types works on many native platforms and fails in WebAssembly, where call_indirect checks the
exact signature at runtime and traps with “indirect call signature mismatch” or “function signature mismatch”. Callbacks registered with the wrong
prototype, qsort comparators declared with int (*)(void*, void*) instead of const void*, and plugin tables of loosely typed function pointers are
typical sources. Assertions turn some of these into readable aborts, and the stack trace points at the call site. The fix is to declare functions with
exactly the type they are called through, or to write small wrapper functions with the expected signature. Building with -Wcast-function-type and
-Wbad-function-cast makes clang warn about many such casts at compile time, which is cheaper than finding them at runtime.
Turning debug builds into a habit
Most teams run debug builds only when something breaks, which means bugs are found late, by users. A better default is to make the debug configuration
the one developers use day to day — it is still fast enough for interactive work — and to keep the release configuration for performance testing and
shipping. Add a script target such as npm run dev:wasm that builds the debug variant with watch mode, and make CI fail if the debug test run reports
any assertion. The cost is a slower inner loop by a third or so; the benefit is that stack overflows, missing exports and pointer bugs surface the day they
are introduced.
Expected output
The debug build reports Aborted(stack overflow …) with a source-level stack trace pointing at a function with a 200 KB local array; moving the array to
the heap fixes it; CI runs tests against the debug build and catches a null-pointer write that the release build had hidden.
Gotchas
- Debugging release builds. Bare aborts give no detail. Rebuild with assertions and
-g. - Shipping debug settings.
SAFE_HEAPand assertions slow and enlarge the build. Keep them out of releases. - Ignoring “called before runtime initialization”. Await the factory before calling exports.
- Stack overflows in release. They corrupt data silently. Check in debug builds and size the stack.
- Changing optimisation and checks at once. Change one variable at a time when bisecting.
Performance note
On a test suite of a C library, the debug build with ASSERTIONS=2 and STACK_OVERFLOW_CHECK=2 ran 1.3× slower than release; adding SAFE_HEAP=1 made it
4.1× slower. The suite still finished in under a minute, and caught two bugs the release build missed.
Frequently Asked Questions
Does -O0 enable assertions automatically?
Yes — ASSERTIONS=1 is the default at -O0. Higher optimisation levels turn them off unless you set them.
Can I keep assertions in production?
ASSERTIONS=1 costs little, but adds glue size and exposes internal messages; most projects keep it to debug builds.
Why does SAFE_HEAP report unaligned access in working code? The code relies on unaligned loads, which Wasm allows but SAFE_HEAP flags. Fix the alignment or ignore those reports deliberately.
How do I debug a crash only seen in production? Capture the abort message and stack, symbolicate it with stored debug information, and reproduce with the debug build.
What does “function signature mismatch” mean? A function pointer was called through a different type than the function’s definition. Fix the declaration or add a wrapper.
Related
- Debugging unreachable-executed traps — trap-level debugging.
- Using Emscripten settings to control memory — stack and heap sizes.
- Tracking down memory corruption in Wasm — when checks are not enough.
- Compiling C++ exceptions for Wasm — readable exception aborts.
← Back to C/C++ to Wasm with Emscripten