Reading Wasm Stack Traces
This guide answers one task: take a stack trace from a WebAssembly module and find the source line it came from — in development, where the tooling can help, and in production, where the module is stripped.
Prerequisites
- [ ] A module that has produced a trace you cannot read.
- [ ]
wasm-objdumpand, for the DWARF path, a browser with the WebAssembly debugging extension. - [ ] The exact build that produced the trace, not a rebuild.
- [ ] A panic hook or equivalent, so there is a trace at all.
The four levels of legibility
What a trace tells you depends entirely on what the build kept, and there are four distinct levels.
A stripped release build gives function indices and byte offsets. wasm-function[412]:0x1f3a2 names
the 412th function and an offset into the module — enough to identify the function if you have the binary,
and nothing more.
A build with the name section gives function names: engine::parse::read_record. That is usually
enough to know where to look, costs a few kilobytes, and is the single highest-value thing to keep.
A build with DWARF gives file and line, and lets a debugger step through source. It costs a great deal of size and is right for development.
A build with source maps gives file and line to the browser’s devtools without the full DWARF weight, which suits a staging environment.
Keep the name section
The name section is a custom section mapping function indices to names. It is the cheapest diagnostic investment available: typically 3–8% of a module’s size, and the difference between a trace you can act on and one you cannot.
# strip debug info but keep names
wasm-opt -Oz --strip-dwarf --strip-producers -o dist/engine.wasm build/engine.wasm
# versus stripping everything, including names
wasm-opt -Oz --strip-debug --strip-producers -o dist/engine.min.wasm build/engine.wasm
wasm-objdump -h dist/engine.wasm | grep -i custom
# Custom start=0x00006a12 end=0x00007f04 (size=0x000014f2) "name"
For a 48 kB module the name section was 5.3 kB — 11% — and it turns every future production trace into something a developer can read without the binary in front of them. That trade is almost always worth taking.
Resolving an index without names
When the trace has only indices, the module itself is the lookup table. wasm-objdump lists functions in
index order, so the index maps directly.
# what is function 412?
wasm-objdump -x dist/engine.min.wasm | grep -m1 'func\[412\]'
# - func[412] sig=7 <engine::parse::read_record> ← if names are present
# with no names, disassemble it and read the code
wasm-objdump -d dist/engine.min.wasm | awk '/^func\[412\]/,/^func\[413\]/' | head -40
The byte offset in the trace locates the instruction within the module, which the disassembly’s left
column also shows — so a trace like 0x1f3a2 can be matched to an exact instruction. That is laborious and
it works, which is occasionally all you need when a production trace is the only evidence available.
Keeping the exact binary for every release is what makes this possible at all. A rebuild from the same commit with a different toolchain version assigns different indices, and the lookup silently gives you the wrong function.
DWARF for development
For stepping through source in the browser, build with DWARF and install the debugging extension.
# Rust: debug info in a development build
cargo build --target wasm32-unknown-unknown # debug profile keeps DWARF
# C/C++
emcc app.cpp -g -O0 -o app.js
With the extension installed, the browser’s sources panel shows the original source, breakpoints work, and locals are inspectable — which is a genuinely good debugging experience and a very large module.
engine.wasm 48 kB (release, names kept)
engine.debug.wasm 612 kB (debug, DWARF)
Keep the two builds separate rather than trying to ship one that serves both. The debug build is for your machine; the release build is for users, and its traces are symbolicated afterwards rather than in the browser.
Symbolicating production traces
The production arrangement that works is: ship a build with names, keep the unstripped binary and its DWARF alongside the release artifacts, and resolve traces offline when one arrives.
# from a reported trace: engine::parse::read_record at 0x1f3a2
llvm-dwarfdump --lookup=0x1f3a2 build/engine.debug.wasm | grep -A2 'Line info'
# Line info: file 'src/parse.rs', line 87, column 13
That requires the debug artifact for the exact release, stored somewhere durable and keyed by the build hash — the same discipline as native symbol servers. Without it a trace names a function and no line, which is usually enough and occasionally not.
Automating the lookup in your error reporting pipeline is worth doing once traffic is meaningful: the
report arrives with a function name, the pipeline resolves it to a line using the stored artifact, and the
issue that reaches a developer says parse.rs:87 rather than a hexadecimal offset.
Getting the trace out of the browser
A trace that only exists in a developer’s console is not much use for a bug that happens to someone else. Capturing and reporting one is a few lines, and the details matter because a WebAssembly trace loses information easily.
Capture the stack property of the error as a string rather than the error object, because the object does
not survive serialisation. Include the module’s build hash, since without it the trace cannot be
symbolicated against the right artifact. And include whatever context identifies the operation, because the
function name alone rarely says what input reached it.
function reportWasmFailure(error, context) {
report({
kind: 'wasm-failure',
message: String(error && error.message || error),
stack: String(error && error.stack || '').slice(0, 4000),
build: BUILD_HASH, // baked in at build time
...context, // operation, input size, capabilities
});
}
try {
return instance.exports.process(ptr, len);
} catch (e) {
reportWasmFailure(e, { op: 'process', len, simd: caps.hasSimd });
throw e;
}
Truncating the stack matters in practice: a deeply recursive failure can produce tens of kilobytes of frames, and an error reporter that accepts it will drop the whole report rather than truncate it for you.
The context fields earn their place the first time a trace arrives from a device you do not have. Knowing
that it failed on a 4 MB input with SIMD enabled, in build a91c3f, is frequently the whole
investigation — the function name tells you where and those three fields tell you why.
Expected output
The same failure, at three levels of build configuration:
# stripped
RuntimeError: unreachable executed
at wasm://wasm/a91c3f:wasm-function[412]:0x1f3a2
at wasm://wasm/a91c3f:wasm-function[398]:0x1e004
# with names
panicked at 'index out of bounds: the len is 32 but the index is 41'
at engine::parse::read_record (wasm-function[412])
at engine::process (wasm-function[398])
# with DWARF, in the browser
panicked at src/parse.rs:87:13
engine::parse::read_record src/parse.rs:87
engine::process src/lib.rs:142
The middle one is what a production build should produce, and it is enough to start almost any investigation.
Gotchas
- No panic hook. There is no message at all, only
unreachable executed. --strip-debugin a release build. Removes the name section along with DWARF; use--strip-dwarfto keep names.- Rebuilding to symbolicate. Indices and offsets differ; keep the exact artifact.
- Traces from a tail-call build. Intermediate frames are gone by design; the trace is shorter than the call history.
- Inlined frames. An inlined function does not appear; the trace names the caller, which can look like the wrong location.
- Assuming the offset is a source position. It is a byte offset into the module and means nothing without the binary.
Performance note
Keeping the name section cost 5.3 kB on a 48 kB module — 11% — and nothing at runtime, since the section is never loaded into memory during execution. A DWARF build was 12.7 times larger and measurably slower to compile in the browser, which is why it belongs in development rather than production. Symbolication offline takes milliseconds and happens once per reported issue rather than once per user.
Frequently Asked Questions
Why is my trace only one frame deep? Either everything was inlined, which release builds do aggressively, or the failure happened in a tail-call chain. Both are correct behaviour and both lose the intermediate frames.
Can I get a trace from inside the module without a panic? Call a host import that throws and catches a JavaScript error, whose stack includes the WebAssembly frames. That is the usual way a module captures its own stack for a non-fatal report.
Do source maps work for WebAssembly?
Yes, through the sourceMappingURL custom section, and browsers use them in devtools. They are smaller
than DWARF and give file and line without variable inspection, which is a reasonable middle ground.
Related
- Debugging Wasm with DWARF and source maps — the full debugging setup.
- Handling panics in Rust Wasm — producing a message to go with the trace.
- Decoding Wasm opcodes for debugging — reading the instruction an offset points at.
Keep the names, keep the artifact, and a production trace stops being a dead end.
← Back to Debugging & Profiling Wasm Modules