Handling Out-of-Memory in Wasm
This page answers one task: a WebAssembly module sometimes runs out of memory on large inputs, and instead of a trap that kills the operation and leaves the instance unusable, the application should fail gracefully — or better, refuse the work up front.
Prerequisites
- [ ] A module that allocates in proportion to its input: decoders, parsers, compressors.
- [ ] Knowledge of its maximum memory setting (
-sMAXIMUM_MEMORYin Emscripten,--max-memoryat link time, or the default). - [ ] Trap handling in place, as in catching Wasm traps in JavaScript.
How running out of memory actually happens
A module’s linear memory starts at its initial size and grows with the memory.grow instruction. Growth can fail for two reasons: the module’s declared
maximum would be exceeded, or the engine cannot reserve more address space or physical memory — on a phone, that can happen far below the
theoretical 4 GiB of a 32-bit memory. When growth fails, memory.grow does not trap; it returns -1. What happens next is up to the allocator.
Most allocators treat that as fatal. Rust’s global allocator returns null, and the standard library’s allocation-error handler aborts — which on
wasm32-unknown-unknown is a trap. Emscripten’s malloc returns NULL; if ABORTING_MALLOC is enabled (the default when memory growth is off), it
aborts instead. C++ new throws std::bad_alloc or aborts. So a failed growth usually becomes a trap a few instructions later, in the middle of
whatever data structure was being built.
Step 1 — check sizes before allocating
The cheapest out-of-memory error is the one that never happens. Most inputs declare their size in a header: an image’s width and height, an archive’s uncompressed size, a document’s page count. Read the header first, compute the memory the operation needs, and refuse if it exceeds a budget:
const MAX_PIXELS: u64 = 64_000_000; // ~256 MB of RGBA
#[wasm_bindgen]
pub fn decode(bytes: &[u8]) -> Result<Vec<u8>, js_sys::Error> {
let header = read_header(bytes).map_err(to_js)?;
let pixels = header.width as u64 * header.height as u64;
if pixels > MAX_PIXELS {
return Err(js_error("LimitError", "ELIMIT", &format!("{}x{} exceeds the limit", header.width, header.height)));
}
decode_body(bytes, &header).map_err(to_js)
}
This also protects against hostile inputs — a 100-byte file claiming to be 50,000 × 50,000 pixels, the image equivalent of a zip bomb. A clear
LimitError lets the UI offer an alternative, such as downscaling on the server.
Step 2 — use fallible allocation for large buffers
For the big allocations the operation depends on, ask for memory in a way that reports failure instead of aborting. In Rust, Vec::try_reserve and
try_reserve_exact return a Result:
let mut out: Vec<u8> = Vec::new();
out.try_reserve_exact(width * height * 4)
.map_err(|_| js_error("OutOfMemoryError", "ENOMEM", "not enough memory for the output image"))?;
In C, check malloc’s result and return an error code — and build with -sABORTING_MALLOC=0 so Emscripten’s malloc returns NULL rather than
aborting. Only the large, input-dependent allocations need this treatment; small internal allocations failing means memory is already exhausted, and
aborting at that point is acceptable.
Step 3 — set a maximum memory deliberately
A module without a declared maximum can try to grow until the engine refuses, which on desktop can mean gigabytes of the user’s RAM before failing. Set a maximum that matches what the application can reasonably use, at link time:
# Rust, via linker args in .cargo/config.toml
rustflags = ["-C", "link-arg=--max-memory=1073741824"] # 1 GiB
# Emscripten
emcc ... -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1gb
A maximum makes failure predictable: it happens at the same point on every device that can provide that much memory, which makes the size budget in step 1 easy to derive. The linker side is covered in setting stack size and memory limits at link time.
Step 4 — recover when it traps anyway
Despite checks, some allocation deep inside a library will eventually fail. The result is a RuntimeError — often unreachable from a Rust abort or
Aborted(OOM) from Emscripten. Treat it like any trap: discard the instance and create a fresh one from the cached module, as in
recovering a module after a trap.
Re-instantiation also releases the old instance’s grown memory once it is garbage-collected — the only way to give that memory back, because
Wasm memory never shrinks.
Step 5 — report it as out-of-memory, not as a crash
Map the cases to one user-facing error class. Size-check failures and fallible-allocation failures already return named errors. For traps, inspect the
message: Emscripten’s Aborted(OOM), or a Rust panic hook message containing “memory allocation of … failed”, identifies the cause, so the wrapper can
throw OutOfMemoryError rather than a generic crash. That distinction matters in monitoring: out-of-memory on huge inputs is an expected limitation;
an unexplained trap is a bug.
Why mobile devices fail first
Desktop browsers usually satisfy a module’s growth up to its maximum. Mobile browsers are far stricter: iOS Safari in particular may refuse to grow
memory well below 1 GiB, and may terminate the whole tab if the page’s total memory rises too high — not with a catchable error but with a reload. A
budget chosen on a laptop can therefore be too generous on a phone. Choose limits per device class, using navigator.deviceMemory where available as a
coarse hint, and test the largest supported input on the oldest supported phone. Streaming algorithms that process input in fixed-size chunks avoid the
problem entirely, because their memory use no longer scales with input size. When a format allows it — tiles for images, frames for video, rows for
CSV — streaming is the most robust out-of-memory fix of all.
Testing the limits deliberately
Out-of-memory handling is code that rarely runs, which means it is usually broken when it finally matters. Test it on purpose. Build a test that feeds
the module an input just under the size budget and asserts success, then one just over it and asserts a LimitError with no memory growth. For the
fallible-allocation path, instantiate the module with a deliberately small maximum — an imported memory with maximum set low, or a test build linked
with a small --max-memory — so that a moderately sized input triggers the failure on a laptop in milliseconds. Assert that the error class is right,
that the instance still works for a small input afterwards, and that memory.buffer.byteLength after the failed call is no larger than before it.
Finally, run the trap path: force an allocation failure in an inner library and check that the wrapper reports OutOfMemoryError, discards the
instance and recovers on the next call. These tests catch the regressions that otherwise reach users — a refactor that replaces try_reserve with
with_capacity, or a dependency upgrade that starts allocating a second copy of the input.
Expected output
Decoding a 30,000 × 30,000 image returns LimitError: 30000x30000 exceeds the limit immediately, with no memory growth. On a phone where a 200 MB output
buffer cannot be reserved, OutOfMemoryError: not enough memory for the output image is thrown and the next, smaller decode succeeds on the same
instance.
Gotchas
- No maximum memory. The module grows until the device refuses. Declare a maximum.
ABORTING_MALLOCleft on. Emscripten’smallocaborts instead of returningNULL. Set-sABORTING_MALLOC=0if C code checks forNULL.- Trusting header sizes blindly. Multiply in 64-bit and check for overflow before comparing with a limit.
- Memory still high after recovery. The trapped instance is still referenced somewhere. Drop all references to it.
- Phone tabs reloading. That is the OS killing the tab, not a Wasm error. Lower limits on mobile.
Performance note
The header check costs microseconds and prevents the expensive failure: an oversized decode that grew memory to the 1 GiB maximum before trapping took
1.9 s and left a 1 GiB instance to discard. try_reserve failure returned in under 1 ms with memory unchanged.
Frequently Asked Questions
Can I catch an out-of-memory panic in Rust with catch_unwind?
Not on wasm32-unknown-unknown with the default abort strategy. Use fallible allocation instead.
Does Memory64 remove the limit? It raises the address-space ceiling, but devices still limit physical memory, so checks remain necessary.
How do I find out how much memory a module is using?
Read memory.buffer.byteLength; for trends over time, see
monitoring Wasm memory in production.
Is memory.grow failure always out-of-memory?
It means the request could not be satisfied — either the declared maximum or the engine’s own limit.
Can JavaScript grow the memory on the module’s behalf before a big job?
Yes — calling memory.grow from JavaScript up front reserves the space, and failing there is clean: no module code has run yet.
Related
- Mapping C error codes to JavaScript errors — reporting ENOMEM from C.
- Growing memory safely from JavaScript — growth from the host side.
- Streaming file uploads into Wasm memory — avoiding the peak altogether.
- Throwing JavaScript exceptions from Rust — the error objects used above.
← Back to Errors & Traps Across the Boundary