Loading Wasm in Node.js with ES Modules
This page answers one task: a Node.js application written as ES modules needs to load a .wasm file that ships next to its JavaScript — reliably,
from any working directory, and without compiling it more often than necessary.
Prerequisites
- [ ] Node.js 18 or newer (20+ recommended).
- [ ] A project using ES modules (
"type": "module"inpackage.json, or.mjsfiles). - [ ] A
.wasmfile — hand-written, or produced by a toolchain targeting raw Wasm or WASI.
How Node finds and loads a module
Node implements the same WebAssembly JavaScript API as browsers: WebAssembly.compile, instantiate, Module, Instance and Memory all behave
identically. What differs is how the bytes arrive. Browsers fetch over HTTP; Node usually reads from the filesystem. The robust way to locate a file
that ships beside your code is relative to the module, not the process’s working directory, and in ES modules that means import.meta.url. A path such
as "./math.wasm" passed to fs.readFile is resolved against process.cwd(), which is wherever the user ran the command — a classic source of
“ENOENT” errors that only appear when the package is installed elsewhere.
Once the bytes are read, compilation is the expensive step: tens to hundreds of milliseconds for large modules. A process should compile once and reuse
the compiled WebAssembly.Module, instantiating it as many times as it needs.
Step 1 — locate and read the file relative to the module
// math.js
import { readFile } from "node:fs/promises";
const url = new URL("./math.wasm", import.meta.url); // file:///…/node_modules/my-pkg/math.wasm
const bytes = await readFile(url); // fs accepts file: URLs directly
fs functions accept URL objects, so there is no need to convert to a path. If a path string is needed for another API, use
fileURLToPath(url) from node:url. Avoid __dirname: it does not exist in ES modules, and recreating it is just a longer way to write the line above.
Step 2 — compile and instantiate with top-level await
const module = await WebAssembly.compile(bytes);
const { exports } = await WebAssembly.instantiate(module, {
env: { log: (x) => console.log("wasm:", x) },
});
export const add = exports.add;
export const fib = exports.fib;
Top-level await lets the module finish loading before anything imports it: a consumer’s import { add } from "./math.js" waits until instantiation is
done, so callers never see an uninitialised export. Keep the compiled module in a module-level variable if other code needs fresh instances — for
example one per worker thread or per request.
Step 3 — stream-compile when the bytes come from a stream
WebAssembly.compileStreaming and instantiateStreaming take a Response (or a Promise of one). Node’s fetch works for remote URLs, and a local file can
be wrapped in a Response to compile while reading:
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";
const stream = Readable.toWeb(createReadStream(url));
const response = new Response(stream, { headers: { "Content-Type": "application/wasm" } });
const { module, instance } = await WebAssembly.instantiateStreaming(response, imports);
The Content-Type must be application/wasm, just as in browsers. For local files the gain over readFile is modest; streaming matters more when the
bytes come over the network, for example from an artefact store at startup.
Step 4 — cache compiled code across runs
V8 caches compiled WebAssembly code in browsers automatically; Node does not persist it between process runs by default. For CLI tools where startup time
matters, enable Node’s compile cache (module.enableCompileCache() in Node 22.1+, which covers JavaScript; Wasm caching support depends on the version)
or serialise nothing at all and rely on Liftoff, V8’s fast baseline compiler, which makes first compilation quick. Measure: for a 2 MB module, baseline
compilation typically takes 20–40 ms, which is acceptable for servers and borderline for tools invoked many times per second. The engine side is described
in how V8 compiles Wasm with Liftoff and TurboFan.
Step 5 — try direct .wasm imports
Node supports importing a WebAssembly file as an ES module behind a flag, implementing the ESM integration proposal:
node --experimental-wasm-modules main.js
import { add } from "./math.wasm"; // exports become ES module exports; imports resolved as module specifiers
The module’s own imports are resolved as ES module specifiers, so a Wasm import from module "./env.js" loads that JavaScript file. It is elegant, but
still experimental; for published packages, prefer the explicit loader above, which works on every Node version and in bundlers. The proposal itself is
covered in
importing Wasm with the ESM integration proposal.
Toolchain-generated loaders
Most real modules come with generated JavaScript. wasm-bindgen’s --target nodejs emits CommonJS that reads the file with fs.readFileSync next to the
glue; --target web emits an ES module whose init uses fetch with import.meta.url, which works in Node 18+ because Node’s fetch supports
file: URLs only in recent versions — so pass the bytes explicitly with init({ module_or_path: await readFile(url) }) for portability. Emscripten’s
-sEXPORT_ES6 -sENVIRONMENT=node output locates its .wasm with import.meta.url itself. Whichever generator you use, check three things: that it
resolves the .wasm relative to the glue file, that it does not compile once per import of the glue, and that it works when the package is installed
in node_modules rather than run from the source tree. A quick test is to npm pack the package, install the tarball into a scratch directory, and
import it from there.
Imports, memory and Node-specific host functions
A module’s imports are just JavaScript functions, and in Node they can do anything Node can: read files with fs, log with console, read
process.env, call native addons. That makes Node a convenient host for modules that need a few capabilities without a full WASI layer. Keep the
import object small and explicit, and remember that every import call crosses the boundary, so a module that logs per iteration will be slow regardless
of the host. Memory works as in browsers: exported or imported WebAssembly.Memory, typed-array views that must be refreshed after growth, and
Buffer.from(memory.buffer, ptr, len) as a convenient zero-copy view when passing bytes to Node APIs such as fs.writeFile or crypto.createHash.
Note that such a Buffer is a view: copy it with Buffer.from(view) before the module can overwrite or free the region.
Using the module from CommonJS code
Plenty of Node code is still CommonJS, and an ES module with top-level await cannot be loaded with require() in older Node versions — require of an
ES module that uses top-level await throws ERR_REQUIRE_ASYNC_MODULE even where requiring ES modules is otherwise supported. CommonJS callers have two
options. They can use dynamic import(), which returns a Promise and works everywhere: const { add } = await import("my-wasm-pkg") inside an async
function. Or the package can offer a CommonJS entry point that exposes an explicit async initialiser instead of relying on top-level await:
const pkg = require("my-wasm-pkg"); await pkg.ready;. The second shape is friendlier for libraries with many CommonJS consumers, and the conditional
exports field in package.json can point require and import at the two entry points. Keep the actual loading code shared between them so there is
only one place that locates, compiles and instantiates the module.
Expected output
Running node app.js from any directory loads math.wasm from the package’s own folder, add(2, 3) returns 5, and compilation happens once per process
— visible as a single WebAssembly.compile in a --cpu-prof profile.
Gotchas
- Paths relative to the working directory.
readFile("./x.wasm")breaks when run elsewhere. Usenew URL(…, import.meta.url). __dirnamein ES modules. It is not defined. Useimport.meta.urlorimport.meta.dirname(Node 20.11+).- Wrong Content-Type for streaming.
instantiateStreamingrequiresapplication/wasm. - Compiling per call. Keep the
WebAssembly.Moduleand instantiate from it. - Relying on experimental flags in libraries. Consumers will not pass
--experimental-wasm-modules.
Performance note
For a 1.8 MB module on Node 22, readFile took 1.2 ms, WebAssembly.compile 31 ms (Liftoff baseline), and instantiate from the compiled module 0.4 ms.
A version that recompiled on every call of a frequently used helper spent 93% of its time in compilation.
Frequently Asked Questions
Does Node support WebAssembly.compileStreaming with fetch?
Yes, for HTTP URLs. For local files, wrap a file stream in a Response as shown.
Can I require() a .wasm file?
No. Read it with fs and compile it, or use a toolchain’s CommonJS glue.
Is the WebAssembly API identical to browsers?
The core API is. Browser-only features such as instantiateStreaming from fetch of relative URLs need adaptation.
How do I share a module between worker threads?
Post the compiled WebAssembly.Module to each worker; it is structured-cloneable and avoids recompiling.
What about Node’s --experimental-wasm-* feature flags?
Recent Node versions enable standardised features — SIMD, threads, exception handling, GC — by default. Flags are only needed for proposals still in
progress.
Related
- Running WASI modules in Node.js — modules that expect WASI imports.
- Targeting Node and browsers from one Wasm package — one package, two loaders.
- Resolving Wasm URLs with import.meta.url — the same technique in browsers.
- Instantiating one module many times — reusing compiled code.
← Back to Wasm in Node.js, Deno & Bun