Symbolicating Wasm Stack Traces in Production
This page answers one task: error reports from production contain WebAssembly stack frames like wasm-function[1234]:0x5a3f because the shipped binary is
stripped, and you need to turn those frames back into function names, source files and line numbers without shipping debug information to users.
Prerequisites
- [ ] A build pipeline that produces the release
.wasm(Rust, C/C++ or another language with DWARF support). - [ ] An error-reporting path that captures
error.stackfrom the browser or runtime. - [ ] Somewhere to store per-build artefacts: an artefact store, a bucket, or an error tracker that accepts debug files.
What a stripped Wasm frame contains
When a WebAssembly function throws or traps, engines include Wasm frames in the JavaScript stack trace. With a name section in the binary, a frame
reads like at parse_header (app.wasm:wasm-function[1234]:0x5a3f); without one, only the function index and the code offset remain:
at app.wasm:wasm-function[1234]:0x5a3f. Release builds usually strip the name section to save size, and always strip DWARF debug information, which can be
larger than the code itself and would otherwise be downloaded by every visitor.
The good news is that 0x5a3f is an exact byte offset into the module’s code, and the function index is exact too. Given the same build’s debug
information, both can be mapped back to a function name and a source location — the same way native crash reporters symbolicate addresses with a symbol
file. The task is to keep that debug information for every release, identify which build a report came from, and run the mapping automatically.
Step 1 — build once with debug info, ship a stripped copy
Compile the release build with debug information, then split it: the stripped binary goes to users, the full one to storage. In Rust:
[profile.release]
debug = true # DWARF in the build output; stripped before shipping
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen target/wasm32-unknown-unknown/release/app.wasm --out-dir pkg --target web --keep-debug
cp pkg/app_bg.wasm debug/app.debug.wasm # full debug copy for symbolication
wasm-opt -O3 --strip-debug --strip-producers pkg/app_bg.wasm -o pkg/app_bg.wasm # shipped copy
The crucial rule: the debug copy and the shipped copy must have identical code. Run every code-changing optimisation before splitting, and only strip
afterwards. If wasm-opt optimises after you saved the debug copy, offsets no longer match. Tools such as wasm-split, or Emscripten’s
-gseparate-dwarf, produce the pair in one step with guaranteed consistency.
Step 2 — stamp each build with an identifier
Reports must say which build they came from. Embed a build id — a content hash of the shipped binary, or the commit plus a build number — and attach it to every error report:
export const WASM_BUILD_ID = "app@2026.10.02+3f9a1c2"; // injected at build time
window.addEventListener("error", (e) => report({ build: WASM_BUILD_ID, stack: e.error?.stack }));
Store the debug file under the same id, and keep it at least as long as that build can still be running in users’ tabs. A content hash computed from the shipped .wasm is the most robust choice, because it can also be recomputed from a
binary found in the wild.
Step 3 — map offsets with the debug file
Given a frame’s code offset, look it up in the debug file’s DWARF line table. llvm-symbolizer understands Wasm:
llvm-symbolizer --obj=debug/app.debug.wasm 0x5a3f
# parse_header
# /src/app/src/header.rs:88:23
Note that browsers report offsets relative to the start of the module, which matches what llvm-symbolizer expects for Wasm objects. If you strip only
DWARF and keep the name section, function names are already in the frames and only file and line need mapping; if you strip everything, names come from
the debug file too. A small script that parses each stack trace, extracts wasm-function[...]:0x... frames, and replaces them with symbolized lines turns a
raw report into a readable one.
Step 4 — automate it in the error pipeline
Manual symbolication does not scale. Most error trackers accept uploaded debug files and symbolicate on ingestion: Sentry supports WebAssembly debug
files keyed by a build id embedded in the binary (its wasm-split tool adds the id and extracts the debug file), and other trackers accept DWARF or
custom mappings through their APIs. Upload the debug file in the same CI job that deploys the stripped binary, and fail the job if the upload fails; a
release without debug files produces reports nobody can read. The reporting side is covered in
reporting Wasm crashes to an error tracker.
Step 5 — keep names cheaply if DWARF is too much
If maintaining debug files is too heavy, a middle ground is to ship the name section only. It adds function names to every frame at a cost of a few percent of binary size — often 5–10% for Rust — and needs no server-side mapping at all. File and line numbers are lost, but a function name is usually enough to start an investigation. Measure the size difference for your module and decide; many teams ship names and keep DWARF for the hard cases.
Grouping reports by root cause
Once frames are symbolicated, error trackers can group reports by their stack, which is what turns thousands of individual reports into a short list of
bugs. Wasm stacks need two adjustments for good grouping. First, ignore frames from the panic machinery and the glue — core::panicking,
rust_begin_unwind, __wbg_* wrappers — which appear in every Rust trap and would otherwise make unrelated crashes look identical; most trackers let you
mark such frames as non-grouping. Second, group on symbolicated function names rather than raw offsets, since offsets change with every build while
function names usually do not, so a bug that persists across releases stays one issue instead of becoming a new one with each deploy. With those rules,
a dashboard sorted by affected users shows which Wasm crashes matter most, and the release in which each first appeared.
Making panic messages useful too
Stack frames say where a failure happened; the message says why. Rust panics compiled with panic = "abort" produce only RuntimeError: unreachable
unless a panic hook captures the message first. Install console_error_panic_hook or a custom hook that records the panic message and location into a
JavaScript-visible place — a global, or a call to your reporting function — before the trap. C and C++ assertions can be routed similarly through
Emscripten’s abort handling. Including the panic message with the symbolicated stack turns “unreachable executed in parse_header” into “index out of
bounds: the len is 4 but the index is 4 at src/header.rs:88”, which is usually enough to fix the bug without reproducing it. Keep messages free of user
data, since they end up in your error tracker.
Expected output
A production report showing wasm-function[1234]:0x5a3f is displayed in the error tracker as parse_header (src/header.rs:88:23) with inlined callers,
the shipped binary contains no DWARF, and every release has its debug file stored under the build id.
Gotchas
- Optimising after saving the debug copy. Offsets no longer match. Strip only after the last code change.
- No build id in reports. You cannot tell which debug file to use. Stamp and report one.
- Shipping DWARF to users. It can double the download. Strip it from the shipped copy.
- Missing uploads. One missed release makes its reports unreadable. Fail CI if the upload fails.
- Grouping on raw offsets. Every release creates new issues. Group on symbolicated names.
- Panics without messages.
unreachablealone says little. Install a panic hook.
Performance note
For a 2.4 MB Rust module, DWARF added 6.8 MB in the debug copy, which never shipped. Keeping only the name section added 140 KB (6%) to the shipped binary.
Symbolicating a 30-frame stack with llvm-symbolizer took about 40 ms.
Frequently Asked Questions
Do all browsers report Wasm frames with offsets? Chromium, Firefox and Safari include Wasm frames, with slightly different formats; parse all three.
Can source maps be used instead of DWARF? Some toolchains emit source maps for Wasm, which error trackers already understand; DWARF is richer, especially for inlined functions.
What about the JavaScript glue? Symbolicate it with ordinary JavaScript source maps from the same build.
Does symbolication work for server-side Wasm? Yes — wasmtime and other runtimes report the same offsets, and some symbolicate locally when debug info is present.
How long should debug files be kept? As long as any release that used them may still be running — typically months for web apps with long-lived tabs and cached builds.
Related
- Debugging unreachable-executed traps — finding the cause once you have the frame.
- Producing reproducible Wasm binaries — build ids that match.
- Converting Wasm back to WAT with wasm2wat — inspecting a function by index.
- Monitoring Wasm memory in production — the other production signal.
← Back to Observability & Error Reporting