ESM Bindings & Module Generation
A raw .wasm binary is not something an application imports directly — it is a blob that must be
fetched, validated, instantiated, and wired to its imports before a single export can be called.
The job of this area is to wrap that blob in an ES module so import { greet } from "./pkg/app.js"
just works: the wrapper owns the fetch-and-instantiate dance, exposes typed functions, and lets a
bundler treat the module like any other dependency. Get the generation step and the bundler
configuration right and the binary becomes a normal, tree-shakeable, type-checked import; get them
wrong and you ship a blocking top-level await, a missing application/wasm MIME type, or a .wasm
that the bundler tried to pre-bundle into oblivion.
This guide covers the full path from a compiled binary to an importable ES module: how wasm-pack
and Emscripten emit the .mjs glue and .d.ts declarations, how to consume them, how to configure
Vite and Webpack to handle the binary, and how to verify the result before it ships.
Prerequisites
- [ ]
wasm-pack≥ 0.12 installed (cargo install wasm-pack) for the Rust path, or Emscripten ≥ 3.1 for C/C++ - [ ] A build target chosen:
wasm-pack build --target web(browser fetch) or--target bundler(bundler-driven) - [ ] A bundler that understands Wasm: Vite ≥ 5 with
vite-plugin-wasm, or Webpack ≥ 5 (nativeasyncWebAssembly) - [ ]
build.target/tsconfigtargetset toes2022oresnextso top-levelawaitcompiles - [ ] A dev server that serves
.wasmwith theapplication/wasmMIME type and correct CORS
How the artifacts are generated and bundled
The generation step turns one compiled binary into three co-located files — the .wasm itself, an
ESM .mjs (or .js) glue module, and a .d.ts declaration — and the bundler then folds those into
your application’s module graph. The glue is what performs the marshaling described in the
wasm-bindgen deep dive: it allocates in
linear memory, copies arguments in, calls the raw export, and decodes results out.
The --target web output embeds a fetch() of the sibling .wasm and an
WebAssembly.instantiateStreaming() call inside the .mjs, so the module is self-contained and runs
in a browser with no bundler at all. The --target bundler output instead emits a bare
import "./app_bg.wasm" and delegates the fetch to the bundler, which gives the bundler full control
over hashing, chunking, and inlining. Choose web for zero-config script-tag usage and CDN delivery;
choose bundler when Vite or Webpack already owns asset resolution.
There is a third option, --target nodejs, which emits CommonJS using require and Node’s
fs.readFileSync instead of fetch — relevant when the same crate ships to both a browser bundle and
a server-side test harness. The artifact set is otherwise identical; only the loader prologue inside
the glue changes. The important mental model is that the binary is constant across all targets and
only the wrapper differs: every target produces the same .wasm, validated identically, and the
choice is purely about how the host obtains and instantiates those bytes. That separation is why you
can switch targets without touching application code that calls the named exports.
Step-by-step workflow
-
Compile and generate bindings. For Rust,
wasm-packruns the compiler andwasm-bindgenin one step. Addwee_allocas a Cargo feature inCargo.tomlfirst, then enable it:# Add wee_alloc as a Cargo feature in Cargo.toml, then pass it via --features wasm-pack build --target web --out-dir pkg --release \ -- --features wee_allocThe
--target webflag outputs a fetch-based ESM wrapper compatible with browsers, while--target bundlerdelegates fetch logic to the host bundler. When detailing wasm-bindgen target configurations and memory allocator selection, the Rust to Wasm compilation guide provides comprehensive benchmarks fordlmallocvswee_alloctradeoffs in constrained environments. -
Or generate ESM glue from C/C++ with Emscripten.
EXPORT_ES6forces ESM output andMODULARIZEwraps the runtime in a factory:emcc src/core.c -o dist/core.mjs \ -O3 \ -sEXPORT_ES6=1 \ -sMODULARIZE=1 \ -sENVIRONMENT=web \ -sALLOW_MEMORY_GROWTH=1 \ -sEXPORTED_FUNCTIONS="['_process_buffer','_compute_hash']" \ -sEXPORTED_RUNTIME_METHODS="['ccall','cwrap']"The
-sEXPORT_ES6=1flag forces ESM output, while-sMODULARIZE=1wraps the runtime in a factory function to prevent global namespace pollution. Cross-reference C/C++ to Wasm with Emscripten when explaining Emscripten’s ESM export flags and glue code generation pipelines, particularly regarding theModuleobject lifecycle andonRuntimeInitializedhooks. -
Inspect the output structure. A
--target webbuild leaves three artifacts side by side:pkg/ ├── core_bg.wasm # compiled binary ├── core.js # ESM wrapper with typed exports + default init() ├── core.d.ts # TypeScript declarations └── package.json # "module", "types", "sideEffects" fields -
Import and instantiate the wrapper. The
--target webdefault export is an asyncinitthat fetches and instantiates the binary; named exports become available after it resolves:import init, { greet } from "./pkg/core.js"; await init(); // fetches core_bg.wasm and instantiates console.log(greet("Wasm")); // "Hello, Wasm!" -
Configure your bundler so it treats the
.wasmas an asset rather than source — see the Vite and Webpack baselines below. For local serving with the right MIME type and headers, the local development server configurations guide covers the dev-server side. -
Optimize and verify the binary with
wasm-opt, then validate exports before shipping (the final two sections).
Bundler integration
Modern bundlers must treat .wasm assets as native modules rather than opaque binary blobs. Correct
configuration requires explicit MIME type resolution (application/wasm), CORS header validation for
cross-origin fetches, and top-level await support for asynchronous instantiation boundaries.
Without these baselines, WebAssembly.instantiateStreaming() fails silently or falls back to
synchronous ArrayBuffer parsing, blocking the main thread and degrading LCP.
Vite configuration (native ESM):
// vite.config.ts
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
build: {
target: "esnext",
},
optimizeDeps: {
// Do not pre-bundle .wasm-bearing packages with esbuild
exclude: ["my-wasm-pkg"],
},
});
The dedicated bundling Wasm ESM with Vite
guide goes deeper on ?init, ?url, assetsInlineLimit, and the dev-versus-build differences.
Webpack 5 configuration:
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true,
topLevelAwait: true,
},
module: {
rules: [
{
test: /\.wasm$/,
type: "webassembly/async",
generator: {
filename: "static/[hash][ext]",
},
},
],
},
};
Webpack’s webassembly/async type produces a Wasm module that is instantiated as part of an async
dependency, which is why topLevelAwait must be enabled — the importing chunk awaits instantiation
implicitly.
A JS binding example
Once the wrapper exists, real work means writing into linear memory and handing the module a
pointer. The glue exposes memory plus an allocator pair; you write, call, and free:
// wasm_bridge.js
import init, { alloc_buffer, free_buffer, process_buffer } from "./pkg/core.js";
export async function processLargePayload(data) {
const wasm = await init(); // resolves to the instance exports
const memory = wasm.memory;
// Allocate inside the module's linear memory and write zero-copy
const ptr = alloc_buffer(data.length);
new Uint8Array(memory.buffer, ptr, data.length).set(data);
try {
return process_buffer(ptr, data.length);
} finally {
// Explicit free prevents linear memory leaks
free_buffer(ptr, data.length);
}
}
Note that the Uint8Array view is constructed after init() and used immediately — if any call in
between grows memory, the view detaches and you must re-create it from memory.buffer. The full set
of conventions for strings, BigInt (for i64), and structs is the subject of
passing complex types across the boundary.
Optimization & tradeoffs
The generated wrapper is where you decide between a lean import and a slow one. Four levers matter:
-
Tree-shaking. Native Wasm ESM imports enable dead-code elimination when exports are statically analyzable.
wasm-packwrites"sideEffects": falseand anexportsmap intopackage.json, so a bundler can drop unused named exports. Keep that field intact; adding a stray side-effecting import to the glue defeats it.{ "sideEffects": false, "module": "pkg/core.js", "types": "pkg/core.d.ts", "exports": { ".": { "import": "./pkg/core.js", "types": "./pkg/core.d.ts" } } } -
modulepreloadand binary preload. The instantiation latency is dominated by fetching the.wasm. A<link rel="modulepreload">for the glue and a preload for the binary let the browser fetch both in parallel with the rest of the bundle:<link rel="modulepreload" href="/pkg/core.js" /> <link rel="preload" href="/pkg/core_bg.wasm" as="fetch" type="application/wasm" crossorigin /> -
Top-level await vs deferred init. Enabling
topLevelAwaitgives synchronous-looking imports but forces the module graph to block until the binary is fetched and instantiated. For critical rendering paths, defer instantiation behind a dynamicimport()so the binary loads off the main bundle:export async function loadWasmModule() { const { default: init, run } = await import("./pkg/core.js"); await init(); return run; } -
Post-compilation size reduction. Run
wasm-optover the binary before bundling to shrink it 15–30%, which directly cuts fetch and decode time:wasm-opt -O3 --enable-bulk-memory --enable-sign-ext \ -o pkg/core_bg.wasm pkg/core_bg.wasmThe full pass catalogue lives in Wasm optimization flags & size reduction.
The decisive tradeoff is fetch eagerly, instantiate lazily: preload the binary so the bytes are on
disk early, but only call init() when the feature is reached. wasm-pack glue is leaner (~2–4 KB)
than Emscripten’s runtime (~15–30 KB), so for size-critical bundles the wasm-bindgen path wins
unless you need to carry a legacy C/C++ codebase forward.
A second tradeoff governs where the binary lives in the output graph. Inlining a small .wasm as a
base64 data URL removes one request but inflates the byte count by roughly a third and makes the
bytes non-cacheable and non-stream-compilable — fine for a sub-kilobyte helper, wrong for anything
larger. Emitting it as a separate hashed asset costs one request but lets the browser stream-compile
during download and cache the binary independently of the JavaScript chunk, so a stable binary is
fetched once and reused across deploys even when the surrounding app code changes. The break-even sits
around a few kilobytes; below it, inline, above it, keep the binary separate and preload it. The same
reasoning extends to chunking: if several routes share one module, give it its own chunk so it is not
duplicated, and let modulepreload warm it ahead of the route that needs it.
Gotchas & failure modes
optimizeDepspre-bundles the binary. Vite’s esbuild pre-bundle step does not understand.wasmand will throwNo loader is configured for ".wasm" files. Add the package tooptimizeDeps.excludeso esbuild leaves it for the plugin.- Wrong MIME type kills streaming.
WebAssembly.instantiateStreaming()requires the responseContent-Typeto be exactlyapplication/wasm. A server returningapplication/octet-streamtriggersTypeError: Incorrect response MIME type. Expected 'application/wasm'.and the browser refuses to stream-compile. Fix the dev server or fall back toArrayBufferinstantiation. - Top-level await without an esnext target. If
build.targetis belowes2022, the bundler emitsTop-level await is not available in the configured target environment. Raise the target. - Forgetting to
await init(). Calling a named export before the defaultinit()resolves throwsTypeError: Cannot read properties of undefinedbecause the wasm exports are not yet bound. Always await the default export first. i64returns surprise you. Withwasm-bindgen, ani64export marshals to a JavaScriptBigInt, not anumber; mixing the two with+throwsTypeError: Cannot mix BigInt and other types. See generating TypeScript types from Wasm.
Verification
Validate the binary’s structure and confirm the wrapper exposes the exports you expect before
shipping. WebAssembly.validate() does a cheap structural check; reading the instance’s exports
object confirms the public surface:
import { readFileSync } from "node:fs";
const bytes = readFileSync("./pkg/core_bg.wasm");
// Structural validation — cheap, catches a corrupt or truncated binary
if (!WebAssembly.validate(bytes)) {
throw new Error("Wasm binary failed structural validation");
}
// Confirm the export surface
const { instance } = await WebAssembly.instantiate(bytes, {});
console.log(Object.keys(instance.exports));
// -> [ 'memory', 'greet', 'alloc_buffer', 'free_buffer', 'process_buffer' ]
For a deeper look at the binary itself, wasm-objdump -x core_bg.wasm lists the export and import
sections, and Chrome DevTools shows the decode time under performance.getEntriesByType("resource")
for the .wasm request. In CI, gate the build on validation:
wasm-pack build --target web --release
wasm-opt -O3 pkg/core_bg.wasm -o pkg/core_bg.wasm
node -e "const fs=require('fs');process.exit(WebAssembly.validate(fs.readFileSync('pkg/core_bg.wasm'))?0:1)"
In this guide
- Generating TypeScript types from Wasm — how
.d.tsfiles are emitted, consumed, and hand-written for a raw.wasm. - Bundling Wasm ESM with Vite —
vite-plugin-wasm,?init/?url,assetsInlineLimit, and dev-versus-build behaviour.
Frequently Asked Questions
What is the difference between --target web and --target bundler?
--target web emits a self-contained .js that fetches and instantiates the sibling .wasm itself,
so it runs in a browser with no bundler. --target bundler emits a bare import "./app_bg.wasm" and
leaves the fetch to Vite or Webpack, giving the bundler control over hashing, chunking, and inlining.
Use web for CDN or script-tag delivery, bundler when a bundler already owns asset resolution.
Why does my .wasm import break Vite’s dev server?
Vite pre-bundles dependencies with esbuild, which has no loader for .wasm, so it errors before the
Wasm plugin runs. Add the offending package to optimizeDeps.exclude and install vite-plugin-wasm
so the binary is handled as an asset.
Do I still need top-level await if I call init() manually?
No. Manually awaiting the default init() export inside an async function gives you full control over
when instantiation happens and avoids blocking the whole module graph. Reserve top-level await for the
ergonomic case where the module is always needed immediately on import.
How big is the generated glue?
wasm-bindgen glue is typically 2–4 KB minified; Emscripten’s modularized runtime is 15–30 KB because
it carries libc shims and an allocator. For a single Rust function the wasm-bindgen path is much
leaner; for a ported C/C++ library Emscripten’s overhead buys you the whole standard library.
Related
- wasm-bindgen deep dive — the glue code that the generated ESM wrapper wraps.
- Rust to Wasm compilation guide — the
wasm-packbuild that emits these artifacts. - C/C++ to Wasm with Emscripten —
EXPORT_ES6and modularized runtime output. - Wasm optimization flags & size reduction — shrinking the binary before it bundles.
- Local development server configurations — serving
.wasmwith the right MIME type and headers.
← Back to Compilation Pipelines & Toolchain Setup