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.
Prerequisites
- [ ]
node18+ (ships nativeWebAssemblyand--experimental-wasm-modules). - [ ]
wasmtimeorwasmerfor a standalone, browser-independent runtime. - [ ]
wabt(wasm2wat,wasm-validate) to disassemble and sanity-check the binary. - [ ] A built
.wasmfile. Awasm32-unknown-unknownbuild runs anywhere; awasm32-wasibuild 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
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.
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.
Related
- Wasm instantiation lifecycle — how
instantiateand theimportObjectbind, mirrored by the CLI runtimes. - How to decode wasm files manually — read the binary
wasm2watdisassembles. - Wasm binary format deep dive — the section layout
wasm-validatechecks.
← Back to Polyfill Alternatives & Fallbacks