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 (native asyncWebAssembly)
  • [ ] build.target / tsconfig target set to es2022 or esnext so top-level await compiles
  • [ ] A dev server that serves .wasm with the application/wasm MIME 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.

flowchart LR SRC["Rust / C++ source"] -->|cargo build / emcc| RAW[".wasm binary"] RAW -->|wasm-bindgen / EXPORT_ES6| GLUE[".mjs ESM glue"] RAW -->|wasm-bindgen| DTS[".d.ts types"] GLUE --> PKG["pkg/ output"] DTS --> PKG RAW --> PKG PKG -->|import| BUNDLER["bundler (Vite / Webpack)"] BUNDLER -->|fetch + WebAssembly.instantiate| APP["application module graph"]

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

  1. Compile and generate bindings. For Rust, wasm-pack runs the compiler and wasm-bindgen in one step. Add wee_alloc as a Cargo feature in Cargo.toml first, 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_alloc

    The --target web flag outputs a fetch-based ESM wrapper compatible with browsers, while --target bundler delegates 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 for dlmalloc vs wee_alloc tradeoffs in constrained environments.

  2. Or generate ESM glue from C/C++ with Emscripten. EXPORT_ES6 forces ESM output and MODULARIZE wraps 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=1 flag forces ESM output, while -sMODULARIZE=1 wraps 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 the Module object lifecycle and onRuntimeInitialized hooks.

  3. Inspect the output structure. A --target web build 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
  4. Import and instantiate the wrapper. The --target web default export is an async init that 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!"
  5. Configure your bundler so it treats the .wasm as 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.

  6. 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-pack writes "sideEffects": false and an exports map into package.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"
        }
      }
    }
  • modulepreload and 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 topLevelAwait gives 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 dynamic import() 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-opt over 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.wasm

    The 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

  • optimizeDeps pre-bundles the binary. Vite’s esbuild pre-bundle step does not understand .wasm and will throw No loader is configured for ".wasm" files. Add the package to optimizeDeps.exclude so esbuild leaves it for the plugin.
  • Wrong MIME type kills streaming. WebAssembly.instantiateStreaming() requires the response Content-Type to be exactly application/wasm. A server returning application/octet-stream triggers TypeError: Incorrect response MIME type. Expected 'application/wasm'. and the browser refuses to stream-compile. Fix the dev server or fall back to ArrayBuffer instantiation.
  • Top-level await without an esnext target. If build.target is below es2022, the bundler emits Top-level await is not available in the configured target environment. Raise the target.
  • Forgetting to await init(). Calling a named export before the default init() resolves throws TypeError: Cannot read properties of undefined because the wasm exports are not yet bound. Always await the default export first.
  • i64 returns surprise you. With wasm-bindgen, an i64 export marshals to a JavaScript BigInt, not a number; mixing the two with + throws TypeError: 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

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.

← Back to Compilation Pipelines & Toolchain Setup