
Mobile WebAssembly Crash Debugging: WASM Trap Guide
Mobile WebAssembly crash debugging occupies a strange middle ground: the code you are debugging is neither native ARM nor ordinary JavaScript — it is compact, sandboxed bytecode running inside the same engine that powers the mobile browser and the embedded web view. On iOS that engine is JavaScriptCore, on Android it is V8, and in both places a WebAssembly failure arrives as a trap, not as the signal, exception, or SIGSEGV your native crash reporter knows how to symbolize. This guide covers the trap classes you will actually hit — unreachable, out-of-bounds linear memory, integer overflow, and call_indirect mismatches — and how to instrument each one.
Why wasm crashes look different on mobile
A native crash on mobile follows a well-trodden path: the OS raises a signal, the runtime unwinds the stack, and your crash reporter symbolicates addresses back to source. A WebAssembly crash follows none of that. The wasm module runs inside a host engine, and when something goes wrong the engine aborts execution by trapping rather than unwinding. The WebAssembly core specification defines traps as the mechanism that terminates execution for anything the module is not allowed to do, and it deliberately gives you very little: no message, no backtrace of your own functions, no source location. The host sees an abrupt return to the JavaScript or native caller, usually a RuntimeError: unreachable.
This matters on mobile specifically because the engine is embedded in a web view or a hybrid shell, and the tooling you are used to — native debuggers, simulators, crash reporters — sits one layer above the engine. A wasm trap inside a WebView on Android is reported as a WebView crash, and if you are not capturing the engine-level details you will chase the wrong stack. This is exactly the blind spot we covered in our mobile WebView crash debugging guide: the outer shell tells you the WebView died, but the root cause lives in the wasm bytecode inside it.
The trap model: unreachable, OOB, and overflow
Everything in wasm that can go wrong maps onto a small set of traps, and knowing them cold is the fastest way to triage. The unreachable instruction is the explicit one: the compiler emits it when it has proven a code path cannot be reached, and executing it traps immediately. In practice this is what a Rust panic! becomes after wasm-bindgen's default panic handler is installed — a panic! that unwinds into a boundary with no unwind table turns into unreachable, so a Rust wasm panic shows up in JavaScript as a bare RuntimeError: unreachable with your message buried in the console.
Two more traps are generated implicitly. Out-of-bounds memory access traps on any load or store outside the current memory size, since every access is bounds-checked. Integer overflow traps in the trapping arithmetic variants — i32.add wraps silently by default, but trapping math traps on overflow. And the indirect call type check traps when call_indirect finds a signature mismatch at runtime.
Here is a minimal trapping module in the text format:
(module
(memory 1) ;; one page = 64 KiB
(func $oob (export "oob")
i32.const 65536 ;; offset past the end of one page
i32.load ;; traps: out-of-bounds memory access
drop)
(func $div0 (export "div0")
i32.const 1
i32.const 0
i32.div_s ;; traps: integer divide by zero
drop))The oob export loads at offset 65,536 — one byte past the end of a single 64 KiB page — and traps; div0 traps because wasm division by zero is defined to trap, not to return infinity.
Linear memory: growth failures and OOB access
Linear memory is the sandboxed address space every wasm module reads and writes, and the single richest source of production traps. A module starts with some number of 64 KiB pages and grows by calling memory.grow, but growth is bounded by the engine's configured maximum and, on mobile, by actual device memory. When WebAssembly.Memory.prototype.grow fails — under memory pressure, or at the declared maximum — it returns -1 rather than throwing, and every subsequent access past the old boundary traps.
The JavaScript side must check that return value explicitly, because a silent -1 is indistinguishable from success if you do not look:
const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 });
function ensureCapacity(pages) {
const prev = memory.buffer.byteLength / 65536;
if (pages > prev && memory.grow(pages - prev) === -1) {
// growth failed: report, degrade, do NOT keep writing
throw new Error("wasm memory growth failed");
}
}On iOS JavaScriptCore and on Android V8 both impose their own ceilings and both get stingier about growth under OS memory pressure. An app that grows its wasm heap aggressively while the device is under pressure will see grow return -1, and the natural follow-on is a trap when the module keeps writing. Bound your heap, preflight growth, and treat -1 as a first-class failure path rather than a surprise.
call_indirect and function-table corruption
Wasm's only form of dynamic dispatch is call_indirect, which indexes into a table of function references and calls an entry after checking its type. Two failures live here: an index that is out of range — calling table index 7 when the table has three entries traps with an "undefined element" fault — and a type mismatch, where the entry exists but its signature does not match the type annotation, so the engine traps before the call.
Both are common because the table is populated by host glue code, not the module's own logic. In Emscripten and wasm-bindgen builds, the JavaScript glue fills the table with function pointers for callbacks and vtable-like dispatch. If a callback is registered after a resize, or a virtual method pointer lands at the wrong index, the trap fires long after the actual bug — at the call, not at the corruption. Record the table index and expected type at the call site so the index is sitting in the report when the trap fires.
Wasm stack overflow is not the native stack overflow
A wasm module has its own evaluation stack, distinct from the host's native stack, and it can overflow independently. Deep recursion inside the module exhausts the wasm stack and traps, usually surfacing as "call stack exhausted" or an unreachable at the recursion boundary. This differs from the native stack overflow problem in our stack overflow and recursion guide: the module's stack is a managed structure the engine sizes at instantiation, and on mobile it is frequently much smaller than on desktop.
The consequences are two-fold. Recursion depth that works in a desktop browser can blow the stack in a mobile web view, because the embedded engine allocates a tighter budget. And since the trap carries no backtrace of your own, a wasm stack overflow looks identical to any other trap unless you capture depth yourself. A depth counter incremented in the recursive entry points turns "unreachable, no context" into "depth 4,096, off by one in the base case."
The JS glue layer: wasm-bindgen and Emscripten
Most mobile wasm is compiled from Rust via wasm-bindgen or from C/C++ via Emscripten, and a large class of crashes lives in the generated glue, not the module. wasm-bindgen marshals values across the Rust/JavaScript boundary, and its crashes are usually one of three shapes: a panic! in Rust surfacing as unreachable on the JS side, a null or undefined passed where Rust expects a valid reference, or a lifetime mismatch where a Rust-side object is used after the JS wrapper dropped it.
The most productive single change for wasm-bindgen crash debugging is to install a panic hook that converts the Rust panic into something observable before the trap swallows it:
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn main() {
std::panic::set_hook(Box::new(|info| {
let msg = info.to_string();
// ship the message to your crash reporter NOW,
// before the panic unwinds into `unreachable`
report_panic(&msg);
}));
}Because the default wasm-bindgen behavior is to abort on panic, anything you want to capture has to be captured inside the hook, before the trap. The same principle holds for Emscripten: build with -sASSERTIONS=1 during development so that OOB accesses and bad casts raise descriptive assertions instead of bare traps, then ship with those assertions off and rely on your own breadcrumbs instead.
Engine differences: V8 on Android, JavaScriptCore on iOS
The same wasm module can trap differently on the two engines. V8, which powers Android's Chrome and WebView, tiers wasm compilation through Liftoff (baseline) and TurboFan (optimizing), and a crash in one tier may not reproduce in the other — a module that traps only under TurboFan signals an engine-level bug, not yours, but you still have to survive it. JavaScriptCore on iOS uses its own BBQ and OMG tiers with the same property. Both are documented publicly — V8's wasm pipeline and Apple's JavaScriptCore framework — and both point to the same posture: when a trap disappears under a different tier, capture the engine version and tier, because the difference is usually the diagnostic.
There are also mobile-specific differences in feature support. WebAssembly SIMD was enabled on the two engines on different schedules, and a module compiled with SIMD that runs on an engine without full SIMD support can trap on misaligned loads. Feature-detect before instantiation and provide a scalar fallback for older devices.
Flutter and Dart compiling to Wasm
The newest mobile wasm surface is Flutter's WebAssembly support, which compiles Dart to wasm instead of JavaScript so web deployments can drop the JS bridge. A Flutter app targeting the browser or a WebView with wasm output produces crashes with a distinctive flavor: Dart's null safety is enforced in the Dart runtime, but once Dart compiles to wasm, an out-of-bounds list access or failed downcast becomes a trap at a boundary your Dart tooling no longer understands. A stack trace stopping at a "wasm-function" with a numeric index is the signature.
The mitigation is to keep Dart-side guards explicit — prefer checked operations and range-checked collection access at the edges of your code — and ship a wasm-specific logging shim that records the last Dart-side context before a trap. Because Dart-to-wasm compilation is still maturing, treat every trap in a Flutter wasm build as potentially an engine or toolchain bug and capture the compiler and engine versions alongside the crash.
Instrumenting wasm traps in production
Everything above converges on one truth: a wasm trap, on its own, is nearly useless as a diagnostic. Attach context at the moment of the trap — the module name and export, linear memory size, recursion depth, the last few table indices dispatched, and the engine version — as breadcrumbs that ride along with the crash report. BugsPulse captures those breadcrumbs alongside the trap so a bare RuntimeError: unreachable becomes an event you can act on the same day. See how it fits your crash-debugging workflow.
Wasm on mobile is no longer exotic — Emscripten games, Rust libraries, and Flutter web builds are all shipping to real devices today — and the trap model is stable enough that you can instrument it once and catch the long tail of failures forever. Start capturing wasm traps with full context and stop guessing at the stack: https://app.bugspulse.com/register.