Chrome's WASM Debugger: What You Get at the WAT Level and Where It Stops
Source: eli-bendersky
Eli Bendersky recently wrote up his experience debugging WASM in Chrome DevTools while building a Scheme compiler targeting WASM. The post is a solid walkthrough of the mechanics. What it leaves room to explore is the broader context: what Chrome’s WASM debugger actually gives you at each layer of the toolchain, where it falls short compared to native debugging, and how the relatively new GC proposal changes the inspection story for reference-typed values.
A Brief History of WASM Debugging
When WebAssembly shipped in browsers in 2017, debugging it was grim. Stack traces showed raw byte offsets in the WASM binary. There was no way to inspect locals or the operand stack. The mental model was: compile, run, read the output, guess.
The situation improved in two distinct ways. First, source maps for WASM landed, allowing a mapping from WASM binary offsets back to WebAssembly Text (WAT) format source positions. This gave you breakpoints and step-through at the WAT instruction level, which is workable when you’re hand-authoring WAT files. Second, and more powerful, DWARF debug info support arrived via the C/C++ DevTools Support extension and was later integrated directly into DevTools. This path matters most for compiled languages: Emscripten with -g, Rust’s wasm32-unknown-unknown target with debug builds, or any toolchain that embeds DWARF sections into the WASM binary. With DWARF, you get original C or Rust source lines, typed variable inspection, and call stacks in terms of your source language.
These two paths serve different audiences. If you’re writing WAT by hand (or generating it with a custom compiler backend, as Eli is doing), DWARF is not in the picture. You work at the WAT level. That’s the scenario worth examining closely.
What WAT-Level Debugging Looks Like
Once you serve your .wasm file over HTTP and open it in Chrome, DevTools decompiles the binary back to WAT and presents it in the Sources panel. This decompilation is lossless in the sense that every instruction is represented faithfully; the only thing missing is your original names and comments if you didn’t embed a name section in the binary.
The name section is worth taking seriously if you’re building a compiler. WAT assemblers like wat2wasm and tools like watgo preserve function and local names from the source text into the binary’s name section by default. In DevTools this means your locals show up as $pair instead of $var0, your functions are labeled by their actual names, and stepping through code is navigable rather than disorienting.
Breakpoints work at the WAT instruction granularity. You can step in, step over, and step out. The call stack panel reflects the WAT-level call chain. The Scope panel shows the current function’s locals and their values. For numeric types (i32, i64, f32, f64) this is straightforward; the value displays as a number. The interesting case is reference types.
GC References and the Type Visibility Problem
The WebAssembly GC proposal, now supported in Chrome, Firefox, and Safari, adds heap-allocated structs and arrays with typed fields, managed by the runtime’s garbage collector. This is what Eli’s Scheme pair example uses: a WASM struct with two fields to represent a cons cell, allocated with struct.new and accessed with struct.get.
From a debugging standpoint, GC references are a meaningful improvement over the old approach of encoding everything as integers in linear memory. When a local holds a (ref $Pair), DevTools can display it as a typed reference. You can expand it and see the struct’s fields by index. Compare this to the alternative: storing a pointer into memory and having to manually read bytes from the Memory inspector to figure out what a value represents.
The limitation is that field names are not preserved through the binary format the same way function names are. The GC spec includes a name subsection for type field names, and wabt/wat2wasm does support it, but DevTools rendering of expanded struct references tends to show field indices rather than the names from your WAT source. This is a tooling gap rather than a fundamental constraint, and it varies by DevTools version. The practical workaround is to keep your structs small enough that field indices are unambiguous.
Arrays under the GC proposal (array.new, array.get) display similarly: as a reference you can expand to see indexed elements. For a Scheme implementation this covers vectors; for a more complex language runtime you might have several array types representing different heap object layouts.
DWARF vs. WAT-Level: When Each Makes Sense
If your WASM comes from Clang or Rust, the right path is DWARF. Emscripten’s -gsource-map flag generates source maps alongside the binary; -g with recent Emscripten versions generates DWARF. The wasm-opt tool from Binaryen can strip or preserve DWARF sections when optimizing. With DWARF in place and a recent Chrome, you get C or Rust source lines, original variable names and types, and inlining information in the call stack.
For hand-authored WAT, or for a custom compiler that generates WAT as an intermediate and then assembles to WASM, emitting DWARF is non-trivial. DWARF is a complex format designed around the abstractions of C and its derivatives: compilation units, subprograms, types as DIEs, address ranges. Mapping a Scheme or ML AST onto DWARF DIEs requires substantial work. Most experimental compilers targeting WASM skip this and accept WAT-level debugging, which is the situation Eli is in.
There is a middle path: WASM source maps. A source map can point from WASM offsets back to a higher-level source file rather than to WAT. If your compiler emits WASM directly (not via WAT), you can track source positions through codegen and emit a source map. This is what AssemblyScript does: it compiles a TypeScript subset to WASM and emits source maps that point at the AssemblyScript source, so DevTools shows your .ts file with breakpoints rather than raw WAT. For a Scheme compiler that goes through WAT as an intermediate text format, the WAT-level debugging is actually reasonable since WAT is legible.
The Serve-Over-HTTP Requirement
One detail that catches people: you cannot open a .html file loading WASM from the local filesystem and expect the WASM to load. Browsers block fetch() on file:// origins by default, and WASM loading goes through fetch(). You need a local HTTP server. Python’s http.server works, but it serves .wasm files without the application/wasm MIME type in older versions, which can cause Chrome to reject the response. Use Python 3.7.2 or later, which adds application/wasm to its MIME type table, or use a purpose-built static server.
This also matters for source maps: if your .wasm has a sourceMappingURL comment pointing to a relative path, Chrome needs to be able to fetch that path over HTTP. Serving from a file URL will silently fail to load the source map.
The Memory Inspector
For code that still uses linear memory alongside GC references (mixed models are common during a transition), Chrome DevTools has a dedicated Memory Inspector for WASM. You can open any i32 local that represents a pointer into memory directly in the inspector, which shows the raw bytes at that address with type interpretations (Int8, Uint32, Float64, etc.) in both little-endian and big-endian. This is the replacement for what would be a x/16xb command in gdb.
The inspector does not automatically follow pointers or understand your heap layout. It’s a hex view with type overlays. For a Scheme runtime that encodes object types in low-order tag bits, you’re still doing the mental arithmetic yourself; the inspector just saves you from context-switching to a separate tool.
Where the Gaps Are
Chrome’s WASM debugger gives you something genuinely useful: breakpoints, step-through, local inspection, typed GC reference expansion, and a memory inspector. For hand-written WAT this covers the common debugging loop.
What it does not give you is conditional breakpoints that understand WASM values well (they exist but are finicky), watchpoints on memory addresses, or any equivalent of a REPL for evaluating expressions in the current WASM scope. The Console can call exported WASM functions while paused, which is a partial substitute, but only for functions you’ve exported.
Firefox’s WASM debugger has comparable capabilities and is worth trying if Chrome’s rendering of a specific situation is confusing. The WABT toolkit (which includes wasm2wat, wasm-objdump, and wasm-validate) is valuable as a complement to browser-based debugging: wasm-objdump -d disassembles locally without a browser, and wasm-validate catches malformed binaries before you waste time staring at a blank page.
For anyone building a compiler targeting WASM, the practical recommendation is to invest in the name section early. The difference between debugging with named locals and named functions versus numeric indices is significant, and most WAT assemblers emit it for free from well-named source text. The GC proposal’s typed references reduce the need to mentally decode pointer-encoded values, which is a real improvement over the linear memory era. Chrome’s DevTools meets you at the WAT level if you give it the names to work with.