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.stack from 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.

From a stripped frame to a source location The build produces a stripped binary for users and keeps a debug copy with DWARF. A production error report carries the build id and frames with code offsets. The symbolicator looks up the matching debug file and maps each offset to a function, file and line. build stripped .wasm + debug copy store debug file keyed by build id error report build id + 0x5a3f frames symbolicate offset → DWARF line table parse_header src/header.rs:88 readable frame

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.

A raw frame and its symbolicated form The raw production frame carries the module name, function index and code offset. The symbolicator uses the debug copy of the same build to resolve the offset to a function name, source file, line and column, including inlined frames. at app.wasm:wasm-function[1234]:0x5a3f raw frame from production build id: app@2026.10.02+3f9a1c2 selects the debug file llvm-symbolizer --obj=app.debug.wasm 0x5a3f lookup parse_header src/header.rs:88:23 readable frame inlined into decode src/lib.rs:40:9 inlined frames too

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. unreachable alone 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.

Size of the same module with different debug data Megabytes for a Rust Wasm module shipped stripped, shipped with the name section only, and the debug copy with full DWARF kept in storage. MB stripped (shipped) 2.4 MB with name section 2.5 MB with DWARF (stored, not shipped) 9.2 MB

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.

← Back to Observability & Error Reporting