Chrome's WASM Debugger Gives You Exactly What the Binary Contains
Source: eli-bendersky
WASM debugging has a reputation for being miserable. For a long time it deserved that reputation: your error was a trap at some bytecode offset, your stack trace was function indices, and your only tools were hex dumps and prayer. That reputation has aged poorly. Chrome DevTools now has genuine WASM debugging support, and Eli Bendersky’s recent walkthrough of using it while building a Scheme compiler’s WASM backend is a clean demonstration of what the workflow looks like. But the workflow he describes is one point in a larger space, and where your WASM came from determines almost everything about what the debugger can show you.
What Chrome Actually Loads
When Chrome encounters a .wasm file, the DevTools Sources panel presents it in WAT format, the S-expression text representation of WASM bytecode. This is not a reconstruction or a best-effort decompilation. WAT is a one-to-one text encoding of the binary format; every instruction, every type, every import maps directly. If your binary was produced by assembling hand-written WAT, as in Eli’s case with watgo, the Sources panel looks almost exactly like what you wrote. Names you gave to functions and locals in the WAT file appear in the debugger because they were encoded into the WASM name section, a custom section that tools can read.
From there, the standard DevTools workflow applies. You set breakpoints by clicking the gutter, step through instructions, and inspect the current frame’s locals in the scope panel. The call stack panel shows WASM frames labeled by function name where names are available. For a reasonably small module with well-named WAT, this is genuinely useful.
The catch is that the debugger can only show you what the binary format encodes. WASM’s type system at the instruction level is narrow: i32, i64, f32, f64, and now reference types. There is no boolean, no struct field name, no source-level variable. When a local holds an integer that conceptually represents a Scheme boolean, the debugger shows you the integer.
The GC Proposal Complicates Inspection
Eli’s example, gc-print-scheme-pairs, uses the WASM GC proposal, which shipped in Chrome 119 in late 2023. The GC proposal adds struct types, array types, and typed reference handles to WASM, making it possible to build garbage-collected object graphs without touching linear memory. This is significant for language implementations: instead of manually managing heap layouts in a flat byte array, you get first-class aggregate types that the engine’s GC understands.
For debugging, though, GC references are largely opaque. When a local holds a (ref $Pair), the debugger can confirm it is a reference and can often tell you it is not null. Inspecting the struct’s fields interactively is where support gets thin. Chrome’s DevTools shows GC references as ref values in the scope panel, and while the tooling continues to improve, you generally cannot expand a GC reference and browse its fields the way you would a JavaScript object. You have to instrument the WAT with explicit logging, call exported inspection functions, or trace through the disassembly manually to understand what a particular reference contains.
This is not unique to Chrome. The GC proposal is young enough that debugger support across the board is still catching up to spec implementation. The practical workaround for now is what Eli’s sample already does: write recursive printing functions in WAT itself, export them, and call them from the browser console to inspect data structures.
Source-Level Debugging: What DWARF Changes
Hand-written WAT gives you WAT-level debugging. Compiled code can give you something much better, provided the compiler emits debug information.
Emscripten and LLVM’s WebAssembly backend both support embedding DWARF debug information in custom sections of the .wasm file. Pass -g to emcc and you get the full DWARF attribute graph: source file references, line number tables, variable location expressions, type descriptions. The binary is larger, but the information is there.
Chrome does not read this DWARF natively. The C/C++ DevTools Support (DWARF) extension, developed by Google, bridges that gap. It installs a DevTools plugin that intercepts WASM debugging events, reads the embedded DWARF, and translates everything back to source coordinates. The result is source-level stepping through C++ or Rust code compiled to WASM: your breakpoints live in .cpp files, your locals show up with their C++ types and names, and the call stack reflects source-level function boundaries rather than WASM function indices.
This is a qualitatively different debugging experience. You are no longer reading WAT at all. The WASM layer disappears, and the debugger behaves as though you are running native code in the browser. Stack-allocated C++ structs show their fields. std::vector expands to show its contents. The gap between what you wrote and what you can inspect collapses.
The tradeoff is setup complexity and binary size. DWARF sections can easily double or triple the .wasm file size. Production builds strip them; debug builds keep them. The extension also adds some overhead to the debugging protocol, and very large binaries with dense debug info can make DevTools feel sluggish during stepping.
The Setup You Actually Need
For hand-written WAT, the setup is minimal. Compile the WAT to WASM with any conformant assembler, watgo and wat2wasm from the WABT toolkit both work, and serve the directory with any static file server. Python’s http.server is fine. The WASM MIME type (application/wasm) is what matters; Chrome refuses to load WASM fetched from file:// URLs because of the security model around shared memory.
# WABT approach
wat2wasm my_module.wat -o my_module.wasm
# Then serve
python -m http.server 8080
For compiled C/C++:
emcc -g -o my_module.html my_module.cpp
Then install the DWARF extension and reload DevTools. The extension auto-detects DWARF sections in loaded WASM files and activates.
For Rust via wasm-pack, the story is similar. Build with --dev to preserve debug info, and the same DWARF extension applies, since rustc also emits DWARF.
What the Local Inspector Misses
Even with DWARF, there is one consistent gap: the WASM value stack itself. WASM uses a stack machine; at any given instruction, there may be intermediate values sitting on the operand stack that are not bound to any named local. In native debugging, these are just registers, and a good debugger can show you register contents. In Chrome’s WASM debugger, the operand stack is not exposed in the UI. You see locals and globals, but not mid-expression intermediates. For most debugging tasks this is fine. For tracking down subtle codegen bugs in a compiler backend, like what Eli was doing, it occasionally forces you to add temporary locals just to make values inspectable.
The WASM debugging specification is ongoing work in the WebAssembly CG, and exposing the value stack is one of the open problems. The semantics are not straightforward because WASM engines can heavily optimize the execution model at runtime; the apparent stack at a breakpoint may not reflect what is physically in registers.
Where This Leaves You
Chrome’s WASM debugger is not a universal solution, but it is a real tool. If you are writing WAT by hand, you get WAT-level debugging with whatever names your assembler preserved. If you compiled from C, C++, or Rust with debug info, you get source-level debugging through the DWARF extension. If you are working with GC references on a new-ish proposal, expect some friction until tooling catches up.
The pattern Eli demonstrates, building small self-contained WAT samples and debugging them in Chrome, is a practical approach for compiler backend work. The feedback loop is tight: write WAT, assemble, reload the browser, set breakpoints, inspect locals. For understanding what your code generation is actually producing at runtime, that loop beats reading bytecode listings in isolation.
The broader lesson is that WASM debuggability is not a single dial from bad to good. It scales with the information your toolchain puts into the binary, and Chrome is good at reading that information when it is there.