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.

Accesses inside and outside linear memory Linear memory runs from address zero to its current size. Accesses inside it succeed even when they hit the wrong data. Only an access whose bytes extend past the end traps with memory access out of bounds, which usually means the pointer was very wrong. linear memory (current size 16 MiB) static data stack heap: live objects freed past the end → trap 0 16 MiB

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.

Narrowing down an out-of-bounds trap If the bad address came from JavaScript, check units, lengths and stale pointers. If it came from module code, rebuild with sanitizers to find the first invalid access. If it appears only after memory growth or a reset, look for pointers or views kept across those events. Where did the bad address come from? JavaScript arguments bytes vs elements, UTF-16 vs UTF-8, stale pointers module code ASan / debug build finds the first bad access after growth or reset pointers kept across instance or view changes

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.

Cost of the debugging aids used to find the trap Relative run time of the same workload with a release build, the release build plus JavaScript range checks at the boundary, and an AddressSanitizer debug build. run time relative to release release build 1 × + boundary range checks 1 × AddressSanitizer build 2.6 ×

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.

← Back to Troubleshooting Common Wasm Errors