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.

Emscripten debug settings and what they catch ASSERTIONS checks glue usage, exports and aborts. STACK_OVERFLOW_CHECK detects the shadow stack overflowing. SAFE_HEAP checks every memory access for null, alignment and range. Sanitizers detect use-after-free, overflows and undefined behaviour. Each adds cost, so they belong in debug builds. setting catches cost -sASSERTIONS=1/2 missing exports, bad glue use, abort reasons small -sSTACK_OVERFLOW_CHECK=2 shadow stack overflow small -sSAFE_HEAP=1 null, unaligned, out-of-range access large -fsanitize=address,undefined UAF, overflows, UB large

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.

From a bare abort to a fixed bug A release build aborts without detail. Rebuilding with ASSERTIONS, STACK_OVERFLOW_CHECK and SAFE_HEAP produces a specific message and a source-level stack trace. The message category points to the fix: an export, an initialisation order, a stack size or a pointer bug. release: "Aborted()" no detail debug build assertions + -g specific message category of bug stack trace source file and line fix + regression test keep it fixed

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_HEAP and 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.

Test-suite run time by build configuration Relative run time of the same test suite with a release build, a debug build with assertions and stack overflow checks, and the debug build with SAFE_HEAP added. run time relative to release release (-O2) 1 × debug: assertions + stack check 1.3 × debug + SAFE_HEAP 4.1 ×

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.

← Back to C/C++ to Wasm with Emscripten