Setting up a local Wasm runtime for testing

This guide answers one question: how do you run and inspect a .wasm module on the command line — without a browser — so you can iterate on compile output, read trap messages, and confirm exports before wiring the module into a page?

The reason to bother is feedback speed and signal quality. A browser round trip means a build, a server, a reload, and a console you have to open; a trap arrives as a stack of numbers with whatever context the page happened to have. On the command line the same module runs in milliseconds, prints a symbolic trap with a backtrace, and lets you invoke a single export with literal arguments — no HTML, no bundler, no MIME type. That makes the local runtime the right place for the first ninety per cent of debugging, and the browser the place where you confirm the load path rather than the logic.

The three hosts below are not interchangeable, and knowing which one answers which question saves a lot of confusion. A standalone runtime such as wasmtime is the fastest to invoke and the most informative about traps, but its execution tier is not V8’s, so it tells you nothing reliable about in-browser speed. Node gives you the exact WebAssembly JavaScript API a page will use, which makes it the right host for testing glue code and import objects. The browser is the only place where streaming instantiation, response headers, and cross-origin isolation are real.

Which host answers which question A standalone runtime is fastest to invoke and best for trap analysis. Node reproduces the exact JavaScript API surface for glue and import-object testing. Only a real browser exercises streaming instantiation, headers and isolation. wasmtime / wasmer invoke one export directly symbolic traps with a backtrace WASI available out of the box use for: does the module compute? do not use for: speed predictions node the same WebAssembly API as a page real import objects, real glue scriptable in your test runner use for: is the binding correct? do not use for: streaming behaviour a real browser MIME types and response headers streaming compilation, tier-up cross-origin isolation and workers use for: does it load and perform? slowest loop — save it for last Move left to right as confidence grows. Debugging logic in the browser first is the most common way to waste an afternoon.

Prerequisites

  • [ ] node 18+ (ships native WebAssembly and --experimental-wasm-modules).
  • [ ] wasmtime or wasmer for a standalone, browser-independent runtime.
  • [ ] wabt (wasm2wat, wasm-validate) to disassemble and sanity-check the binary.
  • [ ] A built .wasm file. A wasm32-unknown-unknown build runs anywhere; a wasm32-wasi build needs a WASI-capable host.

Step-by-step procedure

1. Install a standalone runtime

wasmtime is the reference WASI runtime and the quickest to install. Verify the binary before going further.

curl https://wasmtime.dev/install.sh -sSf | bash
exec "$SHELL"            # reload PATH
wasmtime --version       # e.g. wasmtime 21.0.0

Prefer wasmer if you need its broader embedding story:

curl https://get.wasmer.io -sSfL | sh
wasmer --version

2. Validate and disassemble first

Before running anything, confirm the binary is well-formed and see what it actually exports. A failed wasm-validate here saves you from chasing a runtime error that is really a build bug.

wasm-validate module.wasm && echo "valid"
wasm2wat module.wasm | grep '(export'

3. Run a WASI command module

If your module was built for wasm32-wasi and exports _start (a WASI command), wasmtime run executes it directly, wiring up stdout, args, and the clock.

wasmtime run module.wasm
# pass program args after the file:
wasmtime run module.wasm -- --iterations 1000

4. Call a single exported function

For a wasm32-unknown-unknown library module that exports plain functions (no _start), invoke one by name with --invoke.

# call `add(2, 3)` exported by the module
wasmtime run --invoke add module.wasm 2 3

5. Drive the module from node for browser-API parity

node’s WebAssembly object matches the browser’s, so it is the closest non-browser environment for testing the exact instantiation code your page runs. Use it when you need instantiate, an importObject, or to read linear memory.

// run.mjs  —  node --experimental-wasm-modules run.mjs
import { readFile } from "node:fs/promises";

const bytes = await readFile(new URL("./module.wasm", import.meta.url));
const importObject = {
  env: { memory: new WebAssembly.Memory({ initial: 1 }) },
};
const { instance } = await WebAssembly.instantiate(bytes, importObject);

console.log("exports:", Object.keys(instance.exports));
console.log("add(2, 3) =", instance.exports.add(2, 3));
node --experimental-wasm-modules run.mjs

Expected output

A clean run of the node driver and a wasmtime --invoke call look like this:

$ node --experimental-wasm-modules run.mjs
exports: [ 'memory', 'add' ]
add(2, 3) = 5

$ wasmtime run --invoke add module.wasm 2 3
warning: using `--invoke` with a function that takes arguments is experimental
5

If the module traps instead, wasmtime prints the trap code and a backtrace (richer with WASMTIME_BACKTRACE_DETAILS=1):

Error: failed to invoke command default
Caused by:
    wasm trap: out of bounds memory access
    note: run with `WASMTIME_BACKTRACE_DETAILS=1` for more details
Reading a trap message Out-of-bounds access means a pointer or length is wrong. Unreachable means an assertion or panic path executed. Integer divide by zero and indirect call type mismatch each point at a specific mistake, and the backtrace localises it to a function. out of bounds memory access a pointer or length is wrong — check the caller's arithmetic unreachable executed a panic or assertion fired — this is your own error path indirect call type mismatch a function-table index holds the wrong signature Traps are deterministic and categorical, which makes them far more useful than a browser stack trace of numbered frames. Set WASMTIME_BACKTRACE_DETAILS=1 to get the function names alongside the category.

Gotchas

Error: failed to find function export 'add'. The export name in the binary differs from what you invoked. Rust mangles names and wasm-bindgen may rename them; run wasm2wat module.wasm | grep '(export' and use the exact string you see there.

Error: unknown import: 'wasi_snapshot_preview1::fd_write' has not been defined (running under plain node). The module is a WASI build but you instantiated it with a bare importObject that has no WASI shim. Run it with wasmtime run (which provides WASI), or import a node WASI polyfill (node:wasi) and pass its wasiImport namespace.

error: Validation error: SIMD support is not enabled. The runtime predates or disables a proposal your module uses. Upgrade the runtime, or enable the feature explicitly, e.g. wasmtime run -W simd module.wasm.

TypeError: WebAssembly.instantiate(): Import #0 module="env" error: memory import is required. Your importObject is missing a memory the module imports. Provide env.memory sized to at least the module’s declared minimum page count (each page is 64 KiB).

Performance note

A standalone runtime skips the browser’s JIT warm-up and DOM event loop, so a cold wasmtime run of a tiny module completes in single-digit milliseconds — fast enough to put in a --warmup-backed benchmark loop. But note the inverse: wasmtime’s default Cranelift tier is not V8’s optimizing tier, so absolute throughput numbers from the CLI are a relative signal for iteration, not a prediction of in-browser speed.

Wire the fast host into your test suite rather than treating it as a manual tool. A Node test that instantiates the freshly built binary, calls each export with a known input, and asserts the result catches the majority of regressions — a renamed export, a changed signature, an off-by-one in the pointer arithmetic — in the same second the build finishes. Keep the browser check as a separate, slower job that verifies loading rather than logic, and the two together give full coverage without either one being slow.

Two jobs, two failure classes A Node job runs in seconds on every commit and catches export, signature and arithmetic regressions. A browser job runs less often and catches MIME, header, streaming and isolation failures. Neither substitutes for the other. fast job — every commit instantiate, call every export, assert catches: renamed exports, changed signatures, pointer arithmetic, trap regressions seconds, no browser needed slow job — before release load the real page in a headless browser catches: wrong MIME, missing headers, broken streaming, lost isolation minutes — but only it sees these Running only the slow job wastes minutes per commit; running only the fast one ships a module that never loads.

Keeping the local run faithful

A local host that diverges quietly from the browser is worse than no local host at all, because it produces confidence you have not earned. Two differences account for nearly every divergence, and both are easy to keep in view.

A local host is only useful if what passes there also passes in the browser, and two differences regularly break that. The first is the import surface: a module built for the browser expects the imports its glue supplies, so running it under a bare wasmtime --invoke will fail with an unknown import unless you either stub them or run the JavaScript glue under Node instead. That failure is informative rather than annoying — it tells you exactly which host functions the module depends on.

The second is feature flags. Standalone runtimes gate post-MVP proposals behind command-line switches, and a runtime that predates a feature will reject a module a current browser accepts. When a local run reports a validation error the browser does not, check the runtime’s version and its enabled features before suspecting the binary. The inverse also happens: a runtime with everything enabled will happily run a module using a feature your target browsers lack, which is why the browser check remains part of the pipeline rather than an optional extra.

Recording the runtime version alongside test results makes both cases obvious in hindsight. It costs one line of output and turns “it worked yesterday” into a diff. Pin the runtime version in the same place you pin the compiler, so a local run and a CI run are exercising the same host rather than whatever each machine happened to install.

Frequently Asked Questions

Do I need WASI to run a module locally? Only if the module imports WASI syscalls (fd_write, clock_time_get, etc.). A wasm32-unknown-unknown build with no host imports runs under wasmtime --invoke or node with an empty importObject. A wasm32-wasi build needs a WASI-aware host like wasmtime or wasmer.

Why does node need --experimental-wasm-modules? That flag enables importing .wasm files as ES modules directly. If you instead read the bytes with fs and call WebAssembly.instantiate (as in step 5), you do not need the flag at all — the WebAssembly global is always available in node 18+.

Should I test in a local runtime or in the browser? Both, for different reasons. Use wasmtime/wasmer for fast, deterministic iteration on compile output and trap analysis; use node for WebAssembly-API parity; then verify the real load path in a browser, where MIME, CORS, and instantiateStreaming behavior differ.

← Back to Polyfill Alternatives & Fallbacks