If you can, I would suggest avoiding Emscripten and instead favor freestanding which is much simpler and more flexible; you can compile your Zig code immediately to a .wasm binary that you can then load from JavaScript whichever way you like.
Emscripten is an old dinosaur full of ugly annoying warts and mainly makes sense if you want to write interactive multimedia applications like games without writing a non-trivial amount of Wasm-to-browser-API glue code yourself. If you’re doing raw number crunching, Emscripten doesn’t really offer anything of value.
When targeting Wasm and doing dynamic memory allocation there are two things that are important to consider:
- Who manages the heap?
- How are global variables, the stack and the heap laid out in linear Wasm memory?
If you’re targeting wasm32-freestanding, it’s up to you to manage the heap, which is most easily done by using std.heap.wasm_allocator.
If you’re targeting wasm32-emscripten or wasm32-wasi and linking with libc (which you should, it doesn’t really make sense to not link libc when compiling for those targets), it’s the libc that manages the heap. You should use either std.heap.c_allocator or std.heap.PageAllocator which will allocate everything via the libc. You should not use std.heap.wasm_allocator because then you run into the risk overwriting the libc-managed heap (more details below).
Unfortunately, std.heap.page_allocator (which is also the default backing allocator used by std.DebugAllocator) is an alias for std.heap.wasm_allocator when targeting any wasm32 targets, even when linking libc, which is a mistake and a frequent source of pains and confusion. The Emscripten toolchain might also define the Wasm memory as non-growable, which will cause std.heap.wasm_allocator to fail. Be wary of this if you decide to target Emscripten or WASI.
Regarding how global variables, the stack and the heap is laid out in memory, there are compiler/linker options like --stack [bytes], --global-base=[bytes] or --initial-memory=[bytes] that you can use to fine-tune the layout, but by default a Zig program targeting wasm32-freestanding with no global variables and dynamic memory allocation will reserve a 1 MiB stack (16 pages * 64 KiB each) that is anchored at the end of that 1 MiB chunk of memory and grows downwards:
+----------+
| <- stack |
+----------+
| |
0 1048576
page 0 page 16
If your program has (mutable) global variables, more memory is reserved after the stack and is used for globals:
+----------+------------+
| <- stack | globals... |
+----------+------------+
| | |
0 1048576 1114112
page 0 page 16 page 17
The first time your program allocates something using std.heap.wasm_allocator, it uses @wasmMemoryGrow() to request another Wasm page and starts allocating from that page, which ensures that it won’t clobber the stack or global variables:
+----------+------------+----------------------------+
| <- stack | globals... | std.heap.wasm_allocator -> |
+----------+------------+----------------------------+
| | | |
0 1048576 1114112 1179648
page 0 page 16 page 17 page 18
If you’re targeting Emscripten or WASI and linking with libc, the libc may manage the heap differently. I believe most Wasm linkers define a special __heap_base symbol that points to the end of the globals and marks the start of the heap, which often ends up someplace in the middle of the same page as the globals.
+----------+----------------------+
| <- stack | globals... , heap -> |
+----------+----------------------+
| | ^ |
0 1048576 | 1114112
page 0 page 16 | page 17
|
__heap_base
You might be able to see why std.heap.wasm_allocator and the libc heap might clobber each other if the latter grows into the next page (obviously it depends on exactly how the allocators are implemented). You might also run into similar memory-corruption problems if you are using multiple different allocators that each assume they fully own certain ranges of memory, or if you’re haphazardly writing to arbitrary Wasm memory from JavaScript.