Testing Wasm Modules Under Memory Pressure

This page answers one task: a WebAssembly module works with the memory a developer machine offers, but on phones and in constrained runtimes it runs out — and its behaviour then is untested: it traps, corrupts state, or leaves JavaScript holding a broken instance. You want tests that force low-memory conditions and verify the module fails cleanly.

Prerequisites

  • [ ] A module that allocates significant memory (a codec, a database, an image or ML runtime).
  • [ ] A test runner that can instantiate the module with custom imports or settings (Node, Vitest, Wasmtime, wasm-bindgen-test).
  • [ ] Access to the module’s build flags (Emscripten settings or Rust allocator configuration).

How out-of-memory happens in WebAssembly

A module’s linear memory starts at its initial size and grows with memory.grow, up to the declared maximum or the engine’s limit — 4 GB for 32-bit memories, often less in practice on mobile browsers, where a tab may be killed well before that. memory.grow returns −1 when it cannot grow; it does not trap. What happens next depends on the toolchain. Rust’s default behaviour on allocation failure is to call the allocation error handler, which aborts — a trap with unreachable. Emscripten’s malloc returns NULL when ALLOW_MEMORY_GROWTH is off or growth fails, and C code that does not check it dereferences null and corrupts memory near address zero, or, with ABORTING_MALLOC, aborts. Either way, after an abort the instance is unusable: the next call into it may behave unpredictably.

None of this happens in normal tests, because developer machines have plenty of memory and test inputs are small. The goal is to force it — reliably, quickly — and check that the module reports a recoverable error or that the application detects the broken instance and recreates it.

What happens when allocation fails The allocator needs more memory and calls memory.grow. The engine refuses and grow returns minus one. The allocator then either returns null, calls an error handler that aborts with a trap, or reports an error up to the caller. Only the last path leaves the instance usable. allocation request Vec::with_capacity, malloc memory.grow(n) engine or maximum refuses grow returns −1 no trap yet allocator decides null, abort or error caller handles only clean path

Step 1 — cap the maximum memory for tests

The simplest pressure is a low ceiling. Build a test variant with a small maximum memory, so realistic inputs exceed it quickly:

# Emscripten: growth allowed but capped at 32 MB
emcc src/*.c -O2 -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=32MB -sABORTING_MALLOC=0 -o test/codec.js

# Rust: cap via linker flags
RUSTFLAGS="-C link-arg=--max-memory=33554432" cargo build --target wasm32-unknown-unknown --profile test-lowmem

With a module that imports its memory, the test can create the memory itself: new WebAssembly.Memory({ initial: 17, maximum: 512 }) (pages are 64 KiB, so 512 pages is 32 MiB) and pass it in, varying the maximum per test.

Step 2 — fail growth deliberately from JavaScript

To test failure at a precise moment rather than at a size threshold, make growth fail on demand. A module whose memory is imported can be given a memory with maximum equal to its current size, so every grow fails immediately. For exported memories, wrap the module’s own grow path: Emscripten calls emscripten_resize_heap, which can be overridden in a test build, and Rust allocators can be wrapped (step 3). Then run the operation and assert the outcome:

import { test, expect } from "vitest";
import { instantiateWithMemory } from "./helpers.js";

test("decode reports an error instead of trapping when memory is full", async () => {
  const memory = new WebAssembly.Memory({ initial: 64, maximum: 64 });   // no room to grow
  const codec = await instantiateWithMemory("codec.wasm", memory);
  const result = codec.decode(await readFixture("large.bin"));
  expect(result).toEqual({ ok: false, error: "out_of_memory" });
  expect(codec.decode(await readFixture("small.bin")).ok).toBe(true);   // instance still usable
});

The second assertion is the important one: after a clean failure, the instance must still work for inputs that fit.

Step 3 — inject allocation failures in Rust

Rust’s global allocator can be wrapped in a test build to fail the Nth allocation, or every allocation above a size, while code that uses fallible APIs (Vec::try_reserve, Box::try_new where stable alternatives exist) handles the failure:

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

pub struct FailAfter;
pub static BUDGET: AtomicUsize = AtomicUsize::new(usize::MAX);

unsafe impl GlobalAlloc for FailAfter {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        if BUDGET.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |b| b.checked_sub(layout.size())).is_err() {
            return std::ptr::null_mut();     // simulate failure
        }
        System.alloc(layout)
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        BUDGET.fetch_add(layout.size(), Ordering::Relaxed);
        System.dealloc(ptr, layout)
    }
}

#[cfg(feature = "alloc-fail-tests")]
#[global_allocator]
static A: FailAfter = FailAfter;

Infallible allocations (Vec::push, String::from) that receive null call the allocation error handler and abort, which is correct behaviour for them — the test confirms that the fallible paths you designed for large buffers return errors, and that everything else aborts rather than corrupting memory.

Allocation failure behaviour by toolchain setting Rust infallible allocations abort with a trap on failure while try_reserve returns an error. Emscripten malloc returns null with ABORTING_MALLOC disabled and aborts when it is enabled. Each test should confirm the intended behaviour for the code path in question. toolchain path on failure instance afterwards Rust Vec::push / Box::new abort (trap) unusable, recreate Rust Vec::try_reserve returns Err usable Emscripten malloc + ABORTING_MALLOC=0 returns NULL usable if checked Emscripten malloc + ABORTING_MALLOC=1 abort (trap) unusable, recreate

Step 4 — design fallible APIs for large allocations

Tests only help if the module has a clean failure path to test. For allocations whose size depends on input — image buffers, decompression output, database pages — use fallible APIs and turn failure into an error the caller sees:

pub fn decode(input: &[u8]) -> Result<Image, DecodeError> {
    let (w, h) = header(input)?;
    let len = (w as usize).checked_mul(h as usize).and_then(|p| p.checked_mul(4)).ok_or(DecodeError::TooLarge)?;
    let mut pixels = Vec::new();
    pixels.try_reserve_exact(len).map_err(|_| DecodeError::OutOfMemory)?;
    // ...
}

The checked multiplication matters as much as the fallible reservation: on wasm32, usize is 32 bits, so a large image’s byte count can overflow and allocate a small buffer that later code overruns.

Step 5 — recover from aborts in JavaScript

Some failures will still abort. The JavaScript side should treat a WebAssembly.RuntimeError from the module as fatal for that instance: mark it broken, drop references to it, and create a fresh instance for the next request — compiling once and reusing the WebAssembly.Module keeps re-instantiation cheap. Test this path too: force an abort, catch it, and assert that the next call goes to a new instance and succeeds. The patterns are covered in recovering a module after a trap.

Testing in the browser with realistic limits

Engine limits differ from your test caps. Desktop Chrome allows memories up to 4 GB for wasm32, while iOS Safari tabs are terminated at much lower totals, and the termination is a tab crash rather than a failed grow — no test can catch that from inside. What tests can do is measure peak memory for the heaviest supported input (memory.buffer.byteLength after the operation) and assert it stays within a budget suited to mobile devices, and check that the application refuses inputs above a size limit before starting work. Combine that with a cap on MAXIMUM_MEMORY in production builds, so the module fails with a clean error at a size you chose rather than letting the operating system kill the tab.

Running memory-pressure tests in CI

These tests are fast — they fail quickly by design — and belong in the regular suite. Build the low-memory variant as part of the test job, run the allocation-failure tests with the fault-injecting allocator feature enabled, and keep them separate from performance tests, since a wrapping allocator slows everything slightly. A sweep that runs one operation with the allocation budget set to every value from 0 upward in coarse steps finds failure points nobody anticipated; run it nightly rather than on every push.

Memory pressure from fragmentation

Running out of memory is not always about total size. A long-lived module that allocates and frees buffers of varying sizes can fragment its heap until a large allocation fails even though plenty of memory is free in small pieces — and because linear memory never shrinks, the module then grows again to satisfy it. Tests for long-running modules should replay a realistic sequence of operations many times and assert that memory.buffer.byteLength plateaus rather than climbing. If it climbs, the cause is either a leak or fragmentation; an allocation-counting wrapper distinguishes them, since fragmentation shows live bytes stable while memory grows.

Expected output

With a 32 MB cap, decoding a 48-megapixel image returns { ok: false, error: "out_of_memory" } and the same instance then decodes a small image; the fault-injection sweep finds no failure point that corrupts memory; a forced abort is caught and the next call runs on a fresh instance; and peak memory for the largest supported input stays under the 256 MB mobile budget.

Gotchas

  • Only testing with ample memory. Error paths never run. Cap memory in a test build.
  • Infallible allocations for input-sized buffers. They abort. Use try_reserve and checked arithmetic.
  • Reusing an instance after an abort. State is undefined. Recreate it.
  • Unchecked malloc in C. Null dereferences corrupt low memory. Check every allocation.
  • Assuming the browser fails grow gracefully on mobile. Tabs may be killed instead. Enforce your own limits.

Performance note

Recreating an instance from a cached WebAssembly.Module after an abort took 1.4 ms for a 3 MB module, compared with 38 ms to compile from bytes, which made per-request recovery practical.

Recovering after an abort Milliseconds to get a usable instance again after an abort, by recompiling from bytes compared with instantiating a cached compiled module. ms to a fresh instance compile from bytes 38 ms instantiate cached Module 1.4 ms

Frequently Asked Questions

Does memory.grow failure trap? No — it returns −1. Traps come from what the allocator or code does next.

Can I catch Rust allocation failures with catch_unwind? No — the allocation error handler aborts. Use fallible allocation APIs for large buffers.

Is the 4 GB limit reachable in browsers? On desktop, sometimes; on mobile, practical limits are far lower.

Should production builds cap memory? Often yes, at a level that keeps the tab alive on target devices, with clean errors above it.

Why does memory keep growing when live allocations are stable? Fragmentation: freed blocks are too small for new large requests, so the allocator grows memory. Reuse buffers or use size-class pools.

← Back to Testing & Verifying Wasm Builds