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-objdump and, 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.

What each build lets you read A stripped build yields indices and offsets. Keeping the name section yields function names. Source maps add file and line for devtools. Full DWARF adds stepping and variable inspection at a large size cost. stripped — wasm-function[412]:0x1f3a2 smallest, and nearly unreadable without the binary name section — engine::parse::read_record a few kB, and usually enough — keep this in production source maps — parse.rs:87:13 served separately; good for staging DWARF — step, inspect locals, set breakpoints several times the size; development only

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.

Ship small, keep the symbols The production build keeps only the name section. The unstripped build with DWARF is archived alongside it, keyed by build hash, and used offline to resolve a reported offset to a source line. shipped: names only 48 kB traces name functions archived: full DWARF 612 kB, keyed by build hash symbolicate offline llvm-dwarfdump --lookup in the reporting pipeline parse.rs:87:13 in the issue The archive is the part teams skip, and the part that makes the difference when a trace arrives from a user you cannot reproduce.

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.

How much a trace can tell you A stripped binary gives indices. Keeping the name section gives function names. Shipping DWARF alongside gives file and line, at a cost in artifact size. stripped wasm-function[214] — an index and nothing else name section kept decode_frame — the function, but not the line DWARF available decode_frame at src/decode.rs:88 — file and line The name section is small and worth keeping in production; DWARF is large and belongs beside the build. Source maps let DevTools show the original line without shipping debug data to every visitor.

Gotchas

  • No panic hook. There is no message at all, only unreachable executed.
  • --strip-debug in a release build. Removes the name section along with DWARF; use --strip-dwarf to 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.

Keep the names, keep the artifact, and a production trace stops being a dead end.

← Back to Debugging & Profiling Wasm Modules