Laying Out a Wasm Module’s Memory Map
This page answers one task: you need to know what occupies which addresses in a WebAssembly module’s linear memory — to debug corruption, size the stack, reserve space for something of your own, or interpret a memory dump — and you want to read the layout from the module rather than guess.
Prerequisites
- [ ] A module compiled from C, C++ or Rust with LLVM and linked by
wasm-ld(Emscripten, wasi-sdk, or Rust’s Wasm targets). - [ ]
wasm-objdumporwasm-toolsfor inspecting globals and data segments. - [ ] Access to the module’s exports from JavaScript.
The regions of linear memory
Linear memory is one contiguous byte array starting at address 0. Toolchains based on LLVM and wasm-ld divide it into regions. Static data —
initialised globals, string literals, constant tables, zero-initialised statics — is placed by data segments starting at a low address (typically 1024,
leaving the first kilobyte unused so null-pointer dereferences hit unused memory). The shadow stack holds stack frames for values whose address is taken
and for large locals; it is separate from WebAssembly’s own value stack, which the engine manages invisibly. The heap begins after these and extends to
the end of memory, growing with memory.grow.
wasm-ld defines symbols that mark the boundaries: __data_end (end of static data), __heap_base (start of the heap), and a mutable global
__stack_pointer (current top of the shadow stack). By default the layout is data, then stack, then heap, and the stack grows downwards from
__heap_base towards the data. With --stack-first, the stack comes first, growing down towards address 0, followed by data and heap.
Step 1 — read the boundaries from the module
wasm-objdump -x lists globals and data segments. __stack_pointer appears as a mutable global with its initial value — the stack’s starting top,
equal to __heap_base in the default layout. Data segment offsets and sizes show where static data lies:
wasm-objdump -x -j Global app.wasm
# - global[0] i32 mutable=1 <__stack_pointer> - init i32=1114112
wasm-objdump -x -j Data app.wasm
# - segment[0] memory=0 size=18432 - init i32=1048576
Here static data starts at 1048576 (1 MiB), which indicates --stack-first with a 1 MiB stack, as Rust’s wasm32-unknown-unknown uses by default. Export
__heap_base and __data_end explicitly if you want them at runtime (-Wl,--export=__heap_base,--export=__data_end); Emscripten and wasi-libc use them
internally.
Step 2 — choose the layout and stack size
Stack size is fixed at link time. Too small, and deep recursion or large stack arrays overflow; too large, and memory is wasted on every instance:
# C/C++ with wasm-ld via clang
clang --target=wasm32-wasi -O2 main.c -Wl,-z,stack-size=262144 -Wl,--stack-first -o app.wasm
# Emscripten
emcc main.c -sSTACK_SIZE=256kb -o app.js
# Rust
RUSTFLAGS="-C link-arg=-zstack-size=262144" cargo build --release --target wasm32-unknown-unknown
Prefer --stack-first. In the default layout, an overflowing stack grows down into static data and silently corrupts globals and string constants. With
the stack first, overflow runs below address 0 — which in practice means the stack pointer wraps to a huge address and the next access traps — so
overflows fail loudly instead of corrupting data.
Step 3 — inspect the map at runtime
From JavaScript, read the stack pointer and heap base to see how memory is used. Emscripten exposes stackSave() and the heap base; for other builds,
export the globals:
const { memory, __heap_base, __data_end, __stack_pointer } = instance.exports;
console.table({
memoryBytes: memory.buffer.byteLength,
dataEnd: __data_end.value,
heapBase: __heap_base.value,
stackPointer: __stack_pointer?.value, // only if exported (mutable globals can be exported)
});
Exported globals appear as WebAssembly.Global objects whose .value gives the address. The heap’s used portion depends on the allocator; many expose
statistics (mallinfo in C, allocator-specific functions in Rust).
Step 4 — reserve a region of your own
Sometimes you need a fixed region — a shared scratch buffer with JavaScript, a ring buffer for audio, a region for memory-mapped data. Do not pick a hard-coded address; allocate it statically so the linker places it:
#[repr(C, align(16))]
struct Scratch([u8; 65536]);
static mut SCRATCH: Scratch = Scratch([0; 65536]);
#[no_mangle]
pub extern "C" fn scratch_ptr() -> *mut u8 { unsafe { core::ptr::addr_of_mut!(SCRATCH) as *mut u8 } }
The linker places it in static data (or .bss for zero-initialised), and JavaScript asks for its address. Static allocation keeps it out of the heap,
so it never moves and is never freed.
Step 5 — catch stack overflow early
Beyond --stack-first, toolchains can check the stack explicitly. Emscripten’s -sSTACK_OVERFLOW_CHECK=1 writes a cookie at the stack’s end and checks
it at exit points; =2 instruments every stack pointer update with a bounds check, slower but precise, for debug builds. Clang’s -fstack-protector
guards individual frames against buffer overruns. Use the checks in debug and test builds; release builds rely on --stack-first making overflow trap.
Reading memory dumps
When debugging corruption, a dump of linear memory is easier to read with the map in hand. Addresses below __data_end are static data — compare them
with the original data segments to find globals that changed unexpectedly. Addresses between the stack pointer and the stack base are live stack frames.
Addresses above __heap_base belong to the allocator; allocator headers before each block show sizes and free flags. A small script that labels regions
in a hex dump using these symbols turns a wall of bytes into something navigable.
Multiple memories and imported memory
With the multi-memory proposal, a module can have more than one memory, each with its own layout; LLVM-based toolchains still place everything in memory 0 by default. When memory is imported — for example when several modules share one memory, or JavaScript provides it — each module’s data and stack must not overlap; the linker cannot coordinate across modules, so the embedder must assign each module a separate base, as covered in sharing one memory between two modules.
Measuring how much stack you actually need
Choosing a stack size by guesswork leads to either waste or overflow. Measure instead: in a test build, fill the stack region with a known pattern at startup (Emscripten’s stack cookie mechanism does something similar), run the most demanding realistic workloads — deepest recursion, largest local buffers, longest call chains through callbacks — and afterwards scan from the stack’s far end to find how much of the pattern was overwritten. That high-water mark, plus a safety margin of 50–100%, is a defensible stack size. Repeat the measurement when recursion depth depends on input, such as a parser for nested documents, and either cap the nesting depth in the code or size the stack for the maximum you accept. Remember that worker threads in threaded builds each get their own stack of the configured size, so oversizing multiplies.
Static data that is larger than expected
The static data region sometimes grows surprisingly: large constant tables, embedded fonts, Unicode data from text libraries, or zero-initialised arrays
declared as globals. Zero-initialised statics do not take space in the module file — they are not in data segments — but they still occupy linear memory
from instantiation, raising the initial memory of every instance. wasm-ld --print-map or the map file lists each symbol with its address and size, so
the largest static objects are easy to find; moving rarely used tables to lazily allocated heap memory or separate files reduces the baseline memory cost.
Expected output
wasm-objdump shows a 256 KiB stack placed first, static data at 262144–280576 and the heap from 280576; JavaScript logs the same boundaries from exported
globals; a deliberately deep recursion traps instead of corrupting a global string; and a 64 KiB scratch buffer is placed by the linker and exposed via
scratch_ptr().
Gotchas
- Default layout with deep recursion. Overflow silently corrupts static data. Use
--stack-first. - Hard-coded addresses. The linker may place data there. Allocate statically and ask for the address.
- Assuming the Wasm value stack is the shadow stack. Only address-taken values and large locals live in linear memory.
- Oversized stacks. Every instance reserves them. Size from measurement.
- Separate modules sharing memory without separate bases. Data and stacks overlap.
Performance note
Reducing the stack from the 5 MiB default of an older Emscripten configuration to a measured 512 KiB cut initial memory per instance by 4.5 MiB, which mattered for an app that ran eight worker instances.
Frequently Asked Questions
Why does data start at 1024 and not 0?
wasm-ld leaves the first kilobyte unused by default so null dereferences hit unused memory; --global-base changes it.
Can the stack grow at runtime? No — its size is fixed at link time.
Does Rust use --stack-first?
Rust’s wasm32-unknown-unknown target places the stack first with a 1 MiB default.
Where do thread stacks go in threaded builds? Each thread’s stack is allocated on the heap by the runtime when the thread starts.
How do I find the right stack size? Fill the stack with a pattern in a test build, run the most demanding workloads, and measure the high-water mark; add a margin.
Related
- Understanding the shadow stack in linear memory — the stack in depth.
- Reading wasm-ld map files — where the linker put everything.
- Using Emscripten settings to control memory — stack and heap settings.
- Aligning data in linear memory — alignment rules.
← Back to Linear Memory Management & Allocators