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: fetch plus WebAssembly.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.

Loading a module by hand versus importing it The imperative path needs a fetch, a compile, a hand-built import object and an instantiate call. With ESM integration, an import statement names the module, the host links its imports from other ES modules, and exports appear as bindings. imperative loading fetch the bytes yourself build the import object by hand instantiateStreaming, then read exports dependency invisible to tooling works everywhere today ESM integration import { add } from "./math.wasm" imports resolved from other modules exports become live bindings dependency visible in the graph where supported, far less code

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.

How an instance-phase import is linked The host parses main.js and finds an import of math.wasm. It fetches and compiles the Wasm, reads its import section, loads env.js because an import names it, then instantiates math.wasm with env.js's exports and binds add into main.js. main.js parsed import { add } from ./math.wasm math.wasm compiled import section read env.js loaded named by an import instantiate imports from env.js exports add bound live binding in main.js

Step 1 — check what your runtime supports

Support is uneven, which is the main reason most production code still loads modules imperatively.

Where Wasm ESM integration works in late 2026 Support for instance-phase and source-phase Wasm imports across Node, Deno, browsers and the main bundlers, as of late 2026. Bundlers emulate the instance phase by generating loader code. environment instance import source import Node 22+ --experimental-wasm-modules behind a flag Deno 2 supported supported Chromium browsers origin trial / flag in progress Firefox and Safari not yet not yet Vite (with plugin) emulated plugin-dependent webpack 5 asyncWebAssembly no

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 statement for a .wasm file in Node. Native Wasm imports are behind --experimental-wasm-modules. Without the flag, Node treats the import as unsupported.
  • Unknown module type in a bundler. The bundler needs explicit configuration or a plugin; none of the major bundlers handle .wasm imports 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 module env, which ESM integration would resolve as a package called env. 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.

← Back to ESM Bindings & Module Generation