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_MEMORY in Emscripten, --max-memory at 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.

From a large input to an out-of-memory trap A large input makes the module allocate. The allocator asks for more pages with memory.grow, which returns -1 at the limit. The allocator returns null, and the language runtime treats that as fatal and traps, abandoning the operation mid-way. large input a 200-megapixel image allocator needs 800 MB more memory.grow returns -1 at the limit malloc → null or Rust alloc error abort → trap RuntimeError

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.

Where an out-of-memory condition can be caught Checking sizes from the header refuses impossible work before any allocation. Fallible allocation catches failure at the large buffer and returns an error with the instance intact. Catching the trap is the last line of defence and requires discarding the instance. size check up front read header, compute need refuse above a budget no allocation attempted first line of defence fallible allocation try_reserve / checked malloc returns an error, not a trap instance stays usable for the big buffers catch the trap RuntimeError in JavaScript instance state unknown must re-instantiate last resort

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_MALLOC left on. Emscripten’s malloc aborts instead of returning NULL. Set -sABORTING_MALLOC=0 if C code checks for NULL.
  • 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.

Time to fail on an oversized image Milliseconds until the caller receives an error for an image too large to decode, with an up-front size check, with fallible allocation of the output buffer, and with no checks at all so the module traps. ms until the error reaches JavaScript header size check 0.0 ms try_reserve fails 0.8 ms no checks, trap at max memory 1,900 ms

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.

← Back to Errors & Traps Across the Boundary