Bundling Wasm ESM with Vite

This guide shows how to make Vite fetch, instantiate, and bundle a WebAssembly module as a first-class ESM import — covering vite-plugin-wasm, the ?init and ?url import suffixes, top-level await, and the differences between the dev server and a production build.

Prerequisites

  • [ ] Vite ≥ 5 and Node ≥ 18
  • [ ] A .wasm to import: a wasm-pack --target bundler package, or a standalone .wasm
  • [ ] vite-plugin-wasm and vite-plugin-top-level-await installed
  • [ ] build.target set to esnext (or es2022) so top-level await compiles

How Vite handles a .wasm import

Out of the box Vite’s esbuild dependency pre-bundler has no loader for .wasm and will error on the import. vite-plugin-wasm adds two things: a transform that turns a .wasm?init import into a factory you instantiate yourself, and support for the bare import "./mod.wasm" form that a wasm-pack --target bundler package emits. Because instantiation is asynchronous, any module-level await on the result also needs vite-plugin-top-level-await unless you wrap it in an async function.

Procedure

  1. Install the plugins:

    npm install -D vite-plugin-wasm vite-plugin-top-level-await
  2. Register them in vite.config.ts and raise the build target:

    // 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",          // required for top-level await
        assetsInlineLimit: 4096,   // .wasm above this is emitted as a file, below is inlined
      },
      optimizeDeps: {
        exclude: ["my-wasm-pkg"],  // keep esbuild from pre-bundling the binary
      },
    });
  3. Import with ?init when you have a standalone .wasm and want to supply an import object. The suffix gives you an init factory rather than the instance:

    import init from "./add.wasm?init";
    
    const { exports } = await init({
      // import object — env functions the module expects, if any
    });
    const sum = (exports.add as (a: number, b: number) => number)(2, 3);
    console.log(sum); // 5
  4. Or import with ?url when you want the hashed asset path and intend to fetch and instantiate it yourself — useful for streaming instantiation or passing the URL to a Web Worker:

    import wasmUrl from "./add.wasm?url";
    
    const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {});
    console.log((instance.exports.add as Function)(2, 3));
  5. Or import a wasm-pack --target bundler package directly — the plugin resolves the bare .wasm the package references, and you call its generated entry:

    import init, { greet } from "my-wasm-pkg";
    
    await init();
    console.log(greet("Vite"));
  6. Run dev and build and confirm both serve the binary correctly:

    npm run dev      # serves .wasm with application/wasm, instantiates on the fly
    npm run build    # emits a hashed .wasm asset (or inlines if under assetsInlineLimit)
    npm run preview  # serves the production build locally

Expected output

A production build with a binary above assetsInlineLimit emits the .wasm as a hashed asset alongside the JS chunk that instantiates it:

dist/
├── index.html
└── assets/
    ├── index-9f1c2a.js     # app chunk, awaits instantiation
    └── add-4b7e0d.wasm     # hashed binary, served as application/wasm

If the binary is smaller than assetsInlineLimit, no .wasm file appears — it is base64-inlined into the JS chunk instead, trading a request for a larger, non-cacheable bundle.

Gotchas

  • esbuild pre-bundles the binary. Without optimizeDeps.exclude, the dev server throws No loader is configured for ".wasm" files. Add the Wasm-bearing package to optimizeDeps.exclude so the plugin, not esbuild, handles it.
  • Wrong MIME type in preview. vite preview serves static files; if the host’s static layer does not map .wasm to application/wasm, instantiateStreaming throws Incorrect response MIME type. Expected 'application/wasm'. Vite’s own preview sets it correctly, but a custom adapter may not — verify with curl -I and see the local development server configurations guide for fixing headers.
  • Top-level await needs target: esnext. Leaving the default target produces Top-level await is not available in the configured target environment. Set build.target to esnext or es2022 and ensure optimizeDeps.esbuildOptions.target matches if you override it.
  • Inlining defeats streaming. A .wasm under assetsInlineLimit becomes a base64 data URL, which cannot be stream-compiled and bloats the JS chunk. For anything but a tiny module, lower the limit or set it so the binary is emitted as a separate file.

Performance note

Keeping the .wasm as a separate hashed asset (above assetsInlineLimit) lets the browser stream-compile it during download via instantiateStreaming and cache it independently of the JS, so a content-hash-stable binary is fetched once and reused across deploys. Inlining a 200 KB module as base64 inflates it to ~266 KB and forces a re-download of the whole JS chunk on every binary change — the separate-asset path is faster for everything but trivially small modules.

Frequently Asked Questions

When should I use ?init versus ?url? Use ?init when you want the plugin to own instantiation and you only need to pass an import object. Use ?url when you need the asset URL itself — to call instantiateStreaming manually, hand the URL to a Web Worker, or preload it with a <link>.

Do I always need vite-plugin-top-level-await? Only if you await the instantiation at module top level. If every await init() lives inside an async function you call later, the plugin is unnecessary, though build.target: esnext is still wise.

Why does the binary work in dev but 404 in preview? Dev serves from source over the plugin; the production build emits a hashed filename under assets/. If your code hard-codes the original .wasm path instead of importing it with ?url, the hashed asset is never referenced. Import the binary so Vite rewrites the path.

← Back to ESM Bindings & Module Generation