Debugging Wasm with DWARF and Source Maps

This guide shows how to make Chrome DevTools stop at a breakpoint inside your original Rust or C++ file — with real variable names and types — by embedding DWARF debug information in the .wasm binary and loading the C/C++ DevTools extension.

Prerequisites

  • [ ] Chrome 119+ with DevTools → Settings → Experiments → WebAssembly Debugging: Enable DWARF support enabled
  • [ ] The C/C++ DevTools Support (DWARF) extension installed
  • [ ] Rust toolchain with wasm-pack, or Emscripten (emcc) for C/C++
  • [ ] A local dev server serving .wasm as application/wasm
  • [ ] wabt (wasm-objdump) for verifying the debug sections

What DWARF and source maps actually carry

A source map is a separate .wasm.map JSON file that maps byte offsets in the binary to file, line, and column in your source. It is small and web-native, but it carries no information about variable types — DevTools can show you which line you are on, not what a local Vec<u8> contains. DWARF is the richer format: the same debug-info standard native debuggers use, packed into custom sections (.debug_info, .debug_line, .debug_str) inside the .wasm. With DWARF plus the C/C++ extension, the Scope pane resolves locals to their real types. The tradeoff is size — DWARF can multiply binary size several times over.

The reason both formats are needed at all is that the .wasm the engine executes has no inherent connection to your source. The compiler lowered your Rust or C++ through several intermediate representations, did register allocation, inlined functions, and emitted a flat stream of opcodes. The only record of how byte offset 0x2c41 relates to lib.rs:48 is whatever the compiler chose to write down. DWARF is that record in its most complete form: a tree of debugging-information entries describing every function, scope, variable, and type, plus a line-number program that maps code offsets to source positions. A source map is the same idea stripped to its essentials — just the offset-to-position mapping, no type tree — which is why it is an order of magnitude smaller and why it cannot tell you what is inside a variable. Picking between them is a tradeoff between debugging depth and download weight, and you can even ship both during development and strip both for production.

Procedure

The end-to-end path has five steps: build with debug info, verify it landed in the binary, turn on the DevTools support, set a breakpoint in source, and inspect typed state. The order matters — every later step silently no-ops if an earlier one was skipped, which is why verification comes second rather than last.

1. Build with debug info embedded

For Rust, a development build retains DWARF and the name section. A release build strips them.

wasm-pack build --dev --target web

For C/C++, ask Emscripten for full DWARF:

emcc app.cpp -g -gdwarf-5 -s WASM=1 -o app.js

If you want a source map instead of (or alongside) embedded DWARF from Emscripten — smaller, but without typed variables — use -gsource-map, which emits app.wasm.map and a sourceMappingURL custom section:

emcc app.cpp -gsource-map -s WASM=1 -o app.js

2. Verify the debug sections are present

Do not open DevTools until you have confirmed the binary actually carries debug info. This single check explains most “my breakpoints are grey” reports.

wasm-objdump -h pkg/app_bg.wasm

You want .debug_info, .debug_line, .debug_str, and name in the section list. If they are absent, the build stripped them — re-check that you used --dev / -g and did not run wasm-strip.

3. Enable DWARF support and load the extension

In DevTools → Settings → Experiments, tick WebAssembly Debugging: Enable DWARF support. Install the C/C++ DevTools Support (DWARF) extension from the Chrome Web Store. The extension is the component that parses .debug_info and resolves variable types; the experiment flag tells DevTools to consult it. Restart DevTools after both are set.

4. Set a breakpoint in your original source

Reload the page. Open the Sources panel — your original lib.rs or app.cpp now appears in the file tree under the page’s origin, not as a wasm:// disassembly. Click a line-number gutter to set a breakpoint. Trigger the code path that hits it.

5. Inspect typed variables

When execution pauses, the Call Stack shows real function names (process_frame, not wasm-function[12]), and the Scope pane lists locals with resolved types. Hover a value to see it inline; expand a struct to walk its fields. Step with F10 (over) and F11 (into) exactly as you would in native source.

A few stepping behaviors differ from a typical JavaScript session and are worth knowing up front. Stepping into a function that has no debug info (a library compiled without -g, or a runtime intrinsic) drops you into the raw wasm:// disassembly — step out and set your next breakpoint in source-bearing code instead. Conditional breakpoints work, but the condition is evaluated by the extension against DWARF-resolved variables, so it must reference names that exist in the current scope. And because optimized code can map several source lines to one instruction, stepping in a -O1 or higher build may appear to “jump around” lines; this is correct behavior reflecting the compiler’s instruction scheduling, not a bug in the mapping.

// lib.rs — set a breakpoint on the `let mid` line
#[wasm_bindgen]
pub fn binary_search(haystack: &[i32], needle: i32) -> i32 {
    let (mut lo, mut hi) = (0usize, haystack.len());
    while lo < hi {
        let mid = lo + (hi - lo) / 2;   // ← breakpoint here; inspect lo, hi, mid
        if haystack[mid] < needle { lo = mid + 1; } else { hi = mid; }
    }
    lo as i32
}

Expected output

In the Sources panel you see lib.rs with a blue breakpoint marker on the let mid line. When the function runs, execution halts there and the Scope pane shows:

Local
  haystack: &[i32]    (8 elements)
  needle:   i32 = 42
  lo:       usize = 0
  hi:       usize = 8
  mid:      usize = 4

Function names in the call stack are symbolic, and wasm-objdump -h confirmed the backing sections:

   Custom ".debug_info"  (size=0x0002d6df)
   Custom ".debug_line"  (size=0x00011762)
   Custom "name"         (size=0x00000cc6)

Gotchas

  • Release builds strip and inline. wasm-pack build --release removes DWARF and the name section, and -O2 inlines code so a source line may have no instruction to break on — the breakpoint shows hollow grey and never fires. Debug against --dev / -g -O0.
  • The extension is mandatory for typed variables. Without C/C++ DevTools Support (DWARF) installed and the experiment enabled, you get a wasm:// disassembly instead of your .rs file, and locals show as raw i32. Both pieces are required.
  • The .debug sections are large. Embedded DWARF commonly makes the binary 5–10× bigger. Error message you will not see — there is no warning; you just notice a 700 KB .wasm where the release build is 90 KB. Strip before shipping with wasm-strip app.wasm.
  • Wrong MIME type drops the source map. If the server returns text/plain for the .wasm or .wasm.map, DevTools may not attach the source map and instantiateStreaming throws TypeError: Incorrect response MIME type. Serve application/wasm and application/json.

Performance note

Embedded DWARF is download weight, not runtime weight — the engine ignores .debug_* custom sections during compilation and execution, so a debug build runs no slower because of the debug info itself. The slowness of a --dev build comes from skipping optimization (-O0), not from the DWARF. If you need release-level speed while debugging, build with -O2 -g and accept that inlined lines may be unbreakable.

There is a second-order cost to be aware of: parsing DWARF is work the C/C++ extension does once when the module loads, and for a binary with tens of megabytes of debug info that initial parse can add a noticeable pause before breakpoints become active. This is a one-time, development-only cost — it never reaches your users because you strip DWARF before shipping — but it explains why a huge debug build can feel sluggish to attach to. If attaching is painfully slow, narrow the debug info: compile only the crate you are debugging with full -g and let dependencies build without it, which keeps the .debug_info tree small enough to parse quickly.

Frequently Asked Questions

Can I use DWARF and source maps at the same time? You generally pick one. Embedded DWARF supersedes a source map because it carries strictly more information (types and variables). Emscripten’s -gsource-map and -g are alternative flags; use -g / -gdwarf-5 when you want full variable inspection.

Why do I see the function name but not its variables? The name section alone gives readable function names and line-level breakpoints, but variable types come from .debug_info, which only the C/C++ extension can parse. Confirm .debug_info is present with wasm-objdump -h and that the extension is installed.

Does this work in Firefox? Firefox supports source map-based stepping but not the full DWARF typed-variable experience that the Chrome C/C++ extension provides. For deep variable inspection, use Chrome or Edge.

My breakpoint binds but the variables read as garbage — why? Almost always an optimization artifact: at -O1 and above the compiler may keep a value in a register or reuse a stack slot, so the location DWARF records for a variable is only valid for part of the function. Pausing outside that range shows a stale or unrelated value. Rebuild at -O0 / --dev to get variables that are live for the whole scope.

Can I debug a .wasm I did not compile? Only if it was built with debug info. A third-party release binary has no DWARF and usually no name section, so DevTools can only show you the raw disassembly. You would need the original source and a debug build from it to get source-level debugging.

← Back to Debugging & Profiling Wasm Modules