Inspecting Wasm Memory in Chrome DevTools

This guide shows how to read the raw bytes of a module’s linear memory at a given pointer using the Chrome DevTools Memory inspector, decode a live struct or array from them, and observe how those views change when the module calls memory.grow.

Prerequisites

  • [ ] Chrome 119+ with the Linear Memory Inspector available (it ships with DevTools; no flag needed)
  • [ ] A debug build with DWARF so the Scope pane resolves pointers — wasm-pack build --dev or emcc -g
  • [ ] The C/C++ DevTools Support (DWARF) extension installed for typed Scope values
  • [ ] A breakpoint set in your original source (see the companion guide on DWARF debugging)
  • [ ] instance.exports.memory exposed so the console can read the same buffer

The mental model: offset, not address

A Wasm pointer is a byte offset into the module’s linear memory ArrayBuffer — offset 0 is the first byte of the buffer, not a machine address. The Memory inspector navigates by that offset. When you have a pointer value from the Scope pane (say 1048592), that number is the index into memory.buffer where the data lives. Everything below is about turning that integer into bytes you can read.

This single fact resolves most of the confusion people have when they first open the inspector. There is no virtual address space, no segmentation, no relocation — linear memory is one flat, contiguous, byte-addressed buffer, and a pointer is nothing more than an index into it. The same value means the same byte every time, for as long as the buffer is not reallocated. That is also why pointers are only meaningful relative to one instance’s memory: an offset of 1048592 in module A’s buffer has nothing to do with the same offset in module B. The inspector always shows you the buffer of the memory you selected, so make sure you are looking at the right one if your page instantiates more than a single module.

Procedure

The workflow is: pause where a pointer is live, open the inspector at that offset, decode the bytes, cross-check from the console, and watch how a memory.grow reshapes everything. Each step builds on the previous one, and the final step is the one that trips people up in practice.

1. Pause at a breakpoint that has a pointer in scope

Trigger a breakpoint where a pointer-typed local or argument is live. With DWARF loaded, the Scope pane shows the variable; a slice, Vec, String, or raw *const T all resolve to a pointer value plus, often, a length.

#[wasm_bindgen]
pub fn checksum(buf: &[u8]) -> u32 {
    let mut sum = 0u32;
    for &b in buf {            // ← breakpoint here; `buf` is (ptr, len) in scope
        sum = sum.wrapping_add(b as u32);
    }
    sum
}

2. Open the Memory inspector at the pointer

Right-click the pointer value in the Scope pane and choose Reveal in Memory Inspector Panel (or open the Memory inspector drawer tab and type the offset into the address box). The panel opens a hex + ASCII view starting at that offset.

3. Read the bytes as a struct or array

The inspector lets you reinterpret the bytes under the cursor as a typed value. Select a span and switch the encoding dropdown between Integer (8/16/32-bit, signed/unsigned), Float, and Pointer, and toggle Little / Big endian. Wasm is little-endian, so leave it on Little endian unless you have a reason not to.

Decoding a composite value is a matter of knowing the layout and walking it field by field. For a Rust slice or &str, the variable in scope is really a two-word fat pointer: a ptr and a len, each a 32-bit integer under the default wasm32 target. Jump to ptr, read len bytes, and decode them as UTF-8 (for a string) or as the element type (for a slice). For a struct, fields sit at their aligned offsets in declaration order — a struct { a: u8, b: u32 } is not five bytes but eight, because b is aligned to a 4-byte boundary and three padding bytes sit between them. The inspector’s ASCII gutter is invaluable here: text fields jump out visually, and the gaps of 00 bytes between them are usually padding or zeroed fields, which helps you confirm you have the layout right.

Address     0  1  2  3  4  5  6  7   ASCII
0x100010   48 65 6c 6c 6f 00 00 00   Hello...
           └──────────────┘
           5 bytes of a &[u8] = "Hello"

4. Cross-check from the console

The inspector reads the same buffer your console can. Build a view to confirm what you are seeing — this is also how you read memory when you are not paused at a breakpoint.

const mem = new Uint8Array(globalThis.__wasm.memory.buffer);
const ptr = 0x100010, len = 5;
console.log(new TextDecoder().decode(mem.subarray(ptr, ptr + len))); // "Hello"

5. Watch memory.grow change the picture

Step over a call that allocates enough to trigger memory.grow. The inspector’s view of the old region stays valid (the bytes are copied to the new backing store), but any Uint8Array you built in the console detaches and reads as length 0. Re-create the view to see live data again.

This is the single most common way memory inspection goes wrong, so it is worth understanding mechanically. memory.grow asks the engine for more pages; when the current backing ArrayBuffer cannot be extended in place, the engine allocates a fresh, larger buffer, copies the old contents in, and detaches the old buffer. Detachment is permanent: every typed-array view built over the old buffer now has byteLength === 0 and reads as empty. The DevTools Memory inspector handles this for you by always reading from the module’s current memory, so its hex grid stays live — but your own console views do not, which is why the re-read in the snippet below is not optional.

// Before grow: works. After the module grows memory: this throws or reads empty.
let view = new Uint8Array(globalThis.__wasm.memory.buffer);
globalThis.__wasm.allocate_big();          // internally calls memory.grow
view = new Uint8Array(globalThis.__wasm.memory.buffer); // re-read AFTER grow

Expected output

The Memory inspector shows a hex grid with an ASCII gutter, the cursor at your pointer offset, and the selected encoding decoded at the bottom — for example, four highlighted bytes 2a 00 00 00 interpreted as Int32 (LE) = 42. The page footer shows the current memory size; it jumps from, say, 2 pages (128 KiB) to 18 pages (1.1 MiB) after a memory.grow. Your console TextDecoder output matches the ASCII column exactly, confirming offset and endianness are right.

Gotchas

  • Offset versus address confusion. The number in the Memory inspector is an offset into memory.buffer, not a JS heap address. If you type a JavaScript object’s address you get garbage; only use pointer values that came from Wasm scope or a Wasm export.
  • Views go stale after memory.grow. A Uint8Array over the old memory.buffer detaches when the engine reallocates; reads return 0 and .byteLength becomes 0. The error you may see is TypeError: Cannot perform Construct on a detached ArrayBuffer. Always re-read instance.exports.memory.buffer after any call that might grow.
  • Endianness in the inspector. Wasm load/store are little-endian. If a 32-bit integer reads as a huge nonsense value, you left the dropdown on Big endian — switch to Little endian.
  • Stripped builds hide the pointer. Without DWARF, the Scope pane shows raw numeric locals and you cannot tell which i32 is the pointer. Build with --dev / -g so the slice resolves to a named, typed value.

Performance note

Reading linear memory in DevTools is observation only — it does not copy or move the module’s data, so inspecting a multi-megabyte buffer costs nothing at runtime. The cost that does matter is in your own code: a new Uint8Array(memory.buffer, ptr, len) view aliases the bytes for free, whereas .slice() copies them. When you graduate from inspecting to actually reading memory in production code, prefer the aliasing view — the full set of patterns is in reading Wasm linear memory with typed arrays.

Frequently Asked Questions

Why is my pointer value so large, like 1048592? That is a byte offset near the 1 MiB mark of linear memory, which is normal — allocators place the heap above the static data and shadow stack, so even the first allocation often lands well past offset 0. It is still just an index into memory.buffer.

Can I edit memory bytes in the inspector? Yes — the Memory inspector allows in-place hex edits, which write straight into memory.buffer. Use it to force a value and test a branch, but remember the change is live and the module may overwrite it on the next call.

How do I find a struct’s field offsets? With DWARF loaded, expand the struct in the Scope pane — DevTools shows each field at its resolved offset. To compute them by hand instead, lay out fields in declaration order with the target’s alignment rules; the Memory inspector then lets you verify each field’s bytes.

Why does a multi-byte value look byte-reversed in the hex grid? It is not reversed — it is little-endian, which is how Wasm stores all multi-byte loads and stores. The integer 42 (0x0000002A) appears in memory as 2a 00 00 00. The inspector’s encoding dropdown decodes it correctly when set to Little endian; reading the hex left-to-right by hand is what makes it look backwards.

← Back to Debugging & Profiling Wasm Modules