Fixing Memory Access Out of Bounds Traps
This page answers one task: a WebAssembly call fails with RuntimeError: memory access out of bounds (Firefox: index out of bounds), and you need to
find the access that went outside linear memory and the bug that produced the bad address.
Prerequisites
- [ ] A reproducible case: an input or sequence of calls that triggers the trap.
- [ ] A build with names or debug information, so stack traces show function names.
- [ ] For C or C++, the option to rebuild with Emscripten’s sanitizers; for Rust, a debug build.
What the trap actually means
Every load and store in WebAssembly computes an effective address — the pointer value plus a constant offset — and the engine checks that the whole access lies inside the current size of linear memory. If any byte would fall outside, the instruction traps immediately with this error. Nothing outside the module is ever touched; the sandbox has done its job.
The important consequence is what the trap does not catch. Linear memory is one flat array: an access that is wrong but still inside its bounds — a
use-after-free, an overflow into the next allocation, an uninitialised pointer that happens to be small — succeeds silently and corrupts data. So when the
trap fires, the bad address is usually very wrong: a pointer of 0xFFFFFFF8 from subtracting past zero, a length read from garbage, a pointer from a
different instance, or a pointer computed by JavaScript with the wrong units. It is rarely “memory is full”; out-of-memory shows up differently, as
described in
handling out-of-memory in Wasm.
Step 1 — get a stack trace with names
The error’s stack shows which Wasm function performed the access. With a stripped binary it reads wasm-function[812]:0x3a1f2; rebuild with names
(Rust: debug = 1 or keep the name section; Emscripten: -g2 or --profiling-funcs) and the same frame reads memcpy called from decode_rows. The
innermost frame is often a library routine such as memcpy or memset; the bug is almost always in its caller, which passed the bad pointer or length.
Symbolication of production stacks is covered in
symbolicating Wasm stack traces in production.
Step 2 — check the JavaScript side first
A large share of these traps come from JavaScript passing pointers into the module. Three mistakes dominate:
// 1. Units: passing an element index where the export expects a byte pointer (or vice versa)
exports.sum_f32(f32Offset, n); // should be ptr (bytes), not ptr >> 2
// 2. Lifetime: using a pointer after the module freed it or after a re-instantiation
const ptr = exports.alloc(1024);
resetEngine(); // new instance — ptr belongs to the old memory
exports.process(ptr, 1024);
// 3. Length: passing JS string.length (UTF-16 units) as a UTF-8 byte length
exports.parse(strPtr, text.length); // should be the encoded byte count
Log the pointer and length right before the call, along with memory.buffer.byteLength. An address near 4 GiB means an underflow; an address above
the memory size by a small margin means an off-by-some length; an address that is reasonable but trapping right after a reset points at a stale pointer.
Step 3 — catch the first bad access with sanitizers
For C and C++, rebuild with AddressSanitizer, which instruments every access and reports the first one that touches memory it should not — usually long before anything goes out of bounds:
emcc src/*.c -o build/app.js -fsanitize=address -g -sALLOW_MEMORY_GROWTH -sINITIAL_MEMORY=64mb
The report names the faulty access, the allocation it overflowed and where that allocation was freed if it was a use-after-free. Rust’s safe code cannot
produce these accesses; look at unsafe blocks, FFI into C, and raw pointers received from JavaScript. The sanitizer workflow is described in
catching memory bugs with Emscripten sanitizers.
Step 4 — add cheap bounds assertions at the boundary
Make the wrapper refuse obviously invalid arguments before they reach the module. A check costs nanoseconds and converts a trap deep in a library into an error at the call site with the values that caused it:
function checkRange(ptr, len, label) {
const size = wasm.memory.buffer.byteLength;
if (!Number.isInteger(ptr) || !Number.isInteger(len) || ptr < 0 || len < 0 || ptr + len > size) {
throw new RangeError(`${label}: [${ptr}, ${ptr + len}) outside memory of ${size} bytes`);
}
}
checkRange(ptr, byteLength, "decode input");
exports.decode(ptr, byteLength);
Inside Rust or C, debug assertions on lengths and indexes serve the same role for code paths that do not cross the boundary.
Step 5 — reproduce, fix, and lock it in
Once the faulty access is known, turn the reproducing input into a test. Inputs that trap are often edge cases — empty buffers, maximum sizes, truncated files — so add the neighbours too: length zero, length one, the largest supported length. Fuzzing the entry point with the reproducing input as a seed finds related cases, as in fuzzing a Wasm module. After a trap, remember that the instance may be inconsistent; recover by re-instantiating, as described in recovering a module after a trap.
Reading the faulting instruction
When names are not enough — a large function, inlined code — look at the exact instruction. The frame’s code offset (0x3a1f2) points into the code
section; wasm-tools print or wasm-objdump -d with the same binary lets you find the instruction at that offset, which will be an i32.load,
i64.store, memory.copy or similar, with its static offset= immediate. Reading the few instructions before it shows how the address was computed:
which local held the pointer, what was added to it, and where that local came from. In unoptimised builds the computation maps closely to the source; in
optimised builds expect locals to be reused, but the pattern — a base pointer plus an index times an element size — is usually recognisable. Combine it
with logging the function’s arguments from JavaScript and the faulty value can typically be traced back in a few steps. DevTools can also pause on the
exception: enable “Pause on uncaught exceptions”, reproduce, and inspect the Wasm locals in the Scope panel at the faulting instruction, as described in
inspecting Wasm memory in Chrome DevTools.
Why the same bug sometimes does not trap
Memory-corruption bugs in linear memory are notoriously non-deterministic in appearance. The same off-by-one may write harmlessly into padding on one input, corrupt a neighbour’s data on another, and only trap when a corrupted length is later used as a pointer far from the original bug. Memory growth changes the picture too: a module that grew its memory has a larger valid range, so a wild pointer that trapped at 16 MiB may land inside memory at 64 MiB and corrupt something instead. That is why reproducing with a fixed initial memory, disabling growth during debugging, and running sanitizer builds is so much more effective than adding logging and waiting: sanitizers flag the first wrong access, not the eventual trap.
Expected output
With the wrapper’s range check in place, the bad call fails with RangeError: decode input: [16777200, 16779248) outside memory of 16777216 bytes
pointing at the caller; the underlying bug — a length passed in UTF-16 units — is fixed; and the test suite includes the triggering input and its edge
cases.
Gotchas
- Blaming
memcpy. The innermost frame is rarely the bug. Look at its caller’s arguments. - Assuming the memory is full. Out-of-bounds traps are almost always bad addresses, not exhaustion.
- Stale pointers after re-instantiation. A new instance has a new memory. Discard old pointers.
- Element indexes passed as byte pointers. Shift by the element size consistently.
- Debugging with growth enabled. It moves the boundary. Fix memory size while hunting the bug.
Performance note
The JavaScript range check added about 3 ns per call in Chrome — invisible next to any real work. An AddressSanitizer build ran roughly 2–3× slower and used about twice the memory, which is fine for reproducing a bug and should never ship.
Frequently Asked Questions
Can an out-of-bounds trap compromise the page? No. The engine stops the access before it happens; the page and other modules are unaffected.
Why does it happen only on large inputs? Large inputs reach code paths with bigger lengths or offsets, where arithmetic overflows or wrong units produce addresses past the end.
Does Rust prevent these traps?
Safe Rust panics on bad indexes instead. Traps come from unsafe code, C dependencies or raw pointers supplied by JavaScript.
Will Memory64 change this? The check is the same; addresses are 64-bit, so underflows produce even larger out-of-range values.
Is the trap message the same in all browsers?
Chromium says “memory access out of bounds”, Firefox “index out of bounds”, Safari “Out of bounds memory access”; all are RuntimeErrors.
Related
- Tracking down memory corruption in Wasm — the silent cousin of this trap.
- Why memory.grow invalidates pointers — views versus pointers after growth.
- Creating views into Wasm memory safely — bounds checks on the JavaScript side.
- Understanding Wasm linear memory limits — where the boundary is.
← Back to Troubleshooting Common Wasm Errors