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.

From source to an importable package The compiler emits one raw .wasm binary. wasm-bindgen reads it and generates the ESM glue module and the TypeScript declaration file. All three land in the pkg directory, which the application imports and the bundler resolves, hashes and fetches at run time. Rust / C++ source cargo build · emcc .wasm binary identical for every target .mjs ESM glue loader prologue per target .d.ts types derived from the signatures app_bg.wasm copied through untouched wasm-bindgen pkg/ — one publishable unit package.json points at the glue the glue points at the binary ship all three or none bundler → app graph hashes the asset, emits the fetch and the instantiate call --target web self-fetches · --target bundler delegates to the bundler · --target nodejs swaps fetch for fs.readFileSync

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.

Two bundlers, two resolutions of the same import Vite treats the binary as an asset and hands the glue a hashed URL to fetch. Webpack resolves it through asyncWebAssembly or an asset rule and can inline small binaries as base64. Both end at the same instantiate call, but only one keeps the binary streamable. import init from "./pkg/app.js" Vite binary becomes a hashed asset URL glue fetches it at run time stays streamable · cacheable separately needs the file served as application/wasm Webpack asyncWebAssembly or an asset rule small binaries may be inlined as base64 inlined bytes cannot stream — and grow ~33% raise the asset size threshold to opt out Both paths end in WebAssembly.instantiate — the difference is whether the browser ever sees a separate .wasm response.

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.

Eager import versus a lazily loaded chunk A top-level import of the glue places the binary in the entry chunk, so the first paint waits on it. Moving the import behind an await inside an event handler splits it into its own chunk that downloads and compiles only when the feature is used. top-level import — binary joins the entry chunk app.js app_bg.wasm — downloaded before first paint interactive later, always await import() at the point of use — separate chunk app.js first paint here app_bg.wasm — fetched on first use user clicks the feature Tree shaking never removes the binary: it is one opaque asset, so the only lever is when it is requested. Pair the split with a preload hint if the feature is used on nearly every session.

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.

Publishing the package for other people

The gap between “it works in my app” and “someone else can install it” is small but real, and it is almost entirely in the manifest and the documentation rather than in the code.

A pkg/ directory that works in your own application is not yet a package other teams can install. Three details decide whether the install goes smoothly. The files field must list the binary, the glue and the declaration file — omit any one and consumers get a resolution error rather than a useful message. The main, module and types fields must point at the right artifacts for the target you built; a --target bundler package that advertises a main entry point will be resolved by Node and fail at run time, because that build assumes a bundler will handle the .wasm import. And the version must move whenever the binary changes, even if the JavaScript surface did not, since consumers cache by version and a stale binary with new glue is a confusing failure.

Beyond the manifest, two conventions save consumers real time. Ship the .d.ts even if your own application is untyped, because it is the only machine-readable description of the boundary and it costs nothing to generate. And document the initialisation contract in the README in one sentence: whether init() must be awaited, whether it accepts a URL or a module, and whether calling an export before initialisation throws. Almost every integration issue reported against a Wasm package comes down to that contract being discovered by experiment rather than stated.

If the package targets both browsers and Node, publish two builds under the same version rather than one build with conditional logic. The exports map in package.json can route import and require to the right glue, and the binary is identical between them — only the loader prologue differs, so the duplication is small and the failure modes disappear.

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