Importing Wasm with the ESM Integration Proposal
This page answers one question: can you import a .wasm file directly with an import statement, the way you
import JavaScript — and if so, where does it work today, and what is the difference between the two forms of
the proposal?
Prerequisites
- [ ] A small module with exports to test against — a hand-written one from writing your first WAT module by hand is ideal.
- [ ] Node 22+ for the native experiments, or a current bundler (Vite 5, webpack 5, Rollup with a plugin).
- [ ] Familiarity with the normal path:
fetchplusWebAssembly.instantiateStreaming.
The problem the proposal solves
Loading a WebAssembly module today is imperative. You fetch the bytes, compile them, build an import object by
hand, instantiate, and then pull functions off instance.exports. Every module needs this ceremony, usually
wrapped in generated glue code, and the ceremony is invisible to tools: a bundler cannot see that app.js
depends on app.wasm until it executes the code that fetches it.
The ESM integration proposal makes a .wasm file a first-class module in the JavaScript module graph. An
import statement names it, the host fetches and compiles it alongside the JavaScript modules, resolves its
imports from other modules in the graph, and exposes its exports as the importing module’s bindings. Tools see
the dependency statically, and the boilerplate disappears.
Two forms: instance imports and source imports
The proposal has two phases, and they behave quite differently.
The instance phase is the familiar-looking form. Importing a .wasm file gives you its exports, already
instantiated. The module’s own imports are resolved by treating each import’s module name as a specifier: an
import from "./env.js" with field log is satisfied by the log export of env.js.
// main.js
import { add, memory } from "./math.wasm";
console.log(add(2, 3)); // 5
;; math.wat — its import names a JavaScript module by specifier
(module
(import "./env.js" "log" (func $log (param i32)))
(memory (export "memory") 1)
(func (export "add") (param i32 i32) (result i32)
local.get 0 local.get 1 i32.add))
The source phase returns the compiled WebAssembly.Module instead of an instance, using the import source
syntax. You then instantiate it yourself, as many times as you like, with whatever imports you choose:
import source mathModule from "./math.wasm";
const { exports } = await WebAssembly.instantiate(mathModule, {
"./env.js": { log: (x) => console.log("wasm says", x) },
});
Source imports keep the benefits that matter most — static visibility, the host doing the fetch and compile — without forcing a single instance per page. That suits the way most real modules are used: glue code from wasm-bindgen or Emscripten wants to build its own import object, and a worker may want its own instance.
Step 1 — check what your runtime supports
Support is uneven, which is the main reason most production code still loads modules imperatively.
Browser support moves quickly in this area, so treat the table as a starting point and test the specific versions you ship to. The safe assumption for public web pages is that native support cannot be relied on and a bundler or loader must provide it.
Step 2 — try it natively in Node
wat2wasm math.wat -o math.wasm
cat > env.js <<'EOF'
export function log(x) { console.log("log:", x); }
EOF
node --experimental-wasm-modules main.js
(node:41822) ExperimentalWarning: Importing WebAssembly modules is an experimental feature
5
Node resolves "./env.js" relative to the .wasm file, exactly as it would for a JavaScript import. That is the
detail that makes the instance form workable: the module’s import section becomes part of the module graph.
Step 3 — use it through a bundler today
Bundlers implement the instance form by generating a loader: they turn import { add } from "./math.wasm" into a
fetch, an instantiate, and a top-level await, and they emit the .wasm as an asset.
// webpack.config.js
module.exports = {
experiments: { asyncWebAssembly: true },
module: { rules: [{ test: /\.wasm$/, type: "webassembly/async" }] },
};
// vite.config.js
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default { plugins: [wasm(), topLevelAwait()] };
This is how wasm-pack’s --target bundler output works: the generated JavaScript imports the .wasm file
directly and relies on the bundler to make that import mean something. The configuration details for each bundler
are in bundling Wasm with webpack 5
and bundling Wasm ESM with Vite.
Step 4 — decide how your own package should load
If you publish a package containing a .wasm file, the loading strategy is a choice you make for every consumer.
There are three reasonable options, and the choice determines which environments your package works in without
configuration.
The first is to rely on ESM integration: write import ... from "./pkg_bg.wasm" and require a bundler. That
gives consumers the best tree-shaking and asset handling but fails in plain browsers and in Node without flags.
The second is to load imperatively with new URL("./pkg_bg.wasm", import.meta.url) and
instantiateStreaming. That works everywhere modules work, and bundlers still detect the URL pattern and copy the
asset, as explained in resolving Wasm URLs with import.meta.url.
The third is to ship both, selected by conditional exports.
For most packages in 2026 the second option is the pragmatic default. It costs a few lines of loader code and works in every environment, and it moves to the native form cleanly once support is universal: the loader is replaced by a source import, and nothing else changes.
What changes for glue-code generators
Toolchains that generate glue are the main beneficiaries, and it is worth seeing what the source phase removes
from them. Today a wasm-bindgen web target ships an init() function whose job is to locate the binary,
fetch it, fall back from streaming to ArrayBuffer compilation when the server sends the wrong MIME type, and
cache the resulting module. With a source import, all of that collapses to one line at the top of the glue,
and the host does the locating, fetching and compiling as part of loading the module graph.
That also changes when errors appear. A missing or corrupt .wasm file under imperative loading surfaces as a
rejected Promise from init(), at whatever point the application calls it. Under ESM integration the failure is
a module-graph error: the importing module itself fails to load, and nothing in it runs. That is stricter and
easier to reason about, but it means a feature that only sometimes needs its module should import it
dynamically — await import("./feature.js") — so a failure there does not take down the whole entry point.
Expected output
In a Vite project using the plugin, the production build shows the module emitted as a hashed asset and loaded by generated code:
dist/assets/math-5c2e81a4.wasm 0.08 kB
dist/assets/index-1f9b03ce.js 1.42 kB │ gzip: 0.74 kB
In the browser’s Network panel the .wasm request should carry Content-Type: application/wasm and appear
after the JavaScript that imports it — the bundler has turned the static import into a streaming load.
Gotchas
SyntaxError: Cannot use import statementfor a.wasmfile in Node. Native Wasm imports are behind--experimental-wasm-modules. Without the flag, Node treats the import as unsupported.Unknown module typein a bundler. The bundler needs explicit configuration or a plugin; none of the major bundlers handle.wasmimports with zero configuration.- Top-level await is required. The instance form compiles and instantiates asynchronously, so bundlers emit a
top-level
await. Targets older than ES2022 need the top-level-await plugin or a different loading strategy. - Imports named
env. Many toolchains name the import moduleenv, which ESM integration would resolve as a package calledenv. Rename the import module or use the source phase and build the import object yourself.
Performance note
ESM integration does not make a module run faster — the compiled code is the same. Its benefit is earlier
discovery: when the host sees the import while parsing the module graph, it can start fetching and compiling the
.wasm in parallel with other modules. In a test page with a 900 KB module, a static source import reached a
compiled module 70 ms earlier than an equivalent fetch issued from the top of the entry script, because the
fetch could not start until that script had been downloaded and executed.
Frequently Asked Questions
Will ESM integration replace wasm-bindgen’s glue? Not entirely. Glue still converts strings, objects and errors between the two worlds. What disappears is the loading code; source-phase imports in particular slot underneath existing glue without changing it.
Does an instance import share memory with other importers?
Yes — the module graph instantiates each module once, so every JavaScript module importing the same .wasm
receives the same instance and the same memory. Use the source phase when you need separate instances.
Is this related to the component model? They are separate proposals. Component-model tools such as jco generate ES modules today and may target ESM integration directly as it matures.
Related
- Resolving Wasm URLs with import.meta.url — the portable loading pattern for today.
- Streaming instantiation vs ArrayBuffer instantiation — what the host does under an import.
- Loading Wasm in Rollup and esbuild — emulating the import in two more bundlers.
- Loading Wasm in Node.js with ES modules — the server-side picture.
← Back to ESM Bindings & Module Generation