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.

The confusion this guide clears up is that Vite has two different module pipelines, and a .wasm import behaves differently in each. In development, esbuild pre-bundles dependencies for speed and serves your own source as native ES modules over HTTP; in a production build, Rollup takes over, emits hashed assets, and rewrites every import to point at them. A configuration that satisfies one pipeline and not the other is why “it works in dev but breaks in build” is the single most common report on this topic. Everything below is arranged so both pipelines are configured together.

Two pipelines, one import In development, esbuild pre-bundles dependencies and Vite serves source as native ES modules, so the Wasm plugin must handle the import and esbuild must be told to skip it. In the production build Rollup emits a hashed asset or inlines it as base64 depending on assetsInlineLimit. import "./add.wasm" vite dev — esbuild dependencies are pre-bundled source is served as native ESM esbuild has no .wasm loader fix: optimizeDeps.exclude vite build — Rollup emits add-4b7e0d.wasm, rewrites the path or inlines it below assetsInlineLimit hard-coded paths break here fix: import the binary with ?url Configure both, then verify both — npm run dev and npm run preview exercise different code paths.

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
  7. Preload the binary when it is on the critical path. If the module runs during the first interaction rather than minutes later, tell the browser to start fetching it while the JS chunk is still parsing:

    <link rel="preload" href="/assets/add-4b7e0d.wasm" as="fetch" type="application/wasm" crossorigin>

    Generate the tag from the ?url import rather than hard-coding the hash — the filename changes on every content change, and a stale preload silently downloads a file nobody uses.

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.

Choosing between the three import forms

The three forms are not interchangeable, and picking the wrong one is what produces most of the failure modes below. ?init hands you a factory and keeps instantiation inside the plugin, which is the shortest path when the module has no imports or a small fixed import object. ?url gives you nothing but a string — the hashed, deploy-stable URL of the emitted asset — and leaves fetching and instantiation entirely to you; that is what you want for WebAssembly.instantiateStreaming, for a <link rel="preload"> hint, or for handing the binary to a Web Worker that cannot see your bundler’s module graph. Importing a wasm-pack --target bundler package is the third form, and it is the one to prefer whenever the package exists, because the generated glue already knows the export signatures and the type definitions travel with it.

Which import form to reach for A wasm-pack bundler package is the default choice. A standalone binary that needs an import object suits the init suffix. Work that must control the fetch — streaming, preloading or a worker hand-off — needs the url suffix. what do you have? a wasm-pack package import init, { greet } from "pkg" glue + .d.ts come with it default choice a bare .wasm + imports import init from "./m.wasm?init" you pass the import object plugin owns instantiation you need the URL import url from "./m.wasm?url" streaming · preload · worker you own the fetch Only the ?url form survives a hand-off to a worker, because the worker never runs your bundler's module graph.

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.
  • The dev server and the built output disagree about paths. In dev, Vite serves your source tree, so a hard-coded /src/add.wasm resolves; after a build that file lives at /assets/add-4b7e0d.wasm and the hard-coded path 404s. Any path to a binary must come from an import so Rollup can rewrite it — treat a string literal pointing at a .wasm as a bug even when it works locally.
  • optimizeDeps.exclude is per package, not per file. The exclusion takes the package name that contains the binary, not the path to the .wasm. Passing a file path leaves esbuild pre-bundling the dependency and reproduces the loader error with a configuration that looks correct.
  • 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.

The caching argument is the one that compounds over a project’s life. Your JavaScript changes on almost every deploy; a compiled Wasm module usually does not. Keeping them in separate files means a routine UI change invalidates only the JS chunk, and returning visitors reuse the cached binary with no download at all. Inline the binary and every deploy ships the module again, whether or not a single byte of Rust changed. Set assetsInlineLimit deliberately rather than accepting the default: a value around 4 KB inlines genuinely tiny helper modules where an extra request would dominate, while anything larger stays a cacheable asset.

What inlining costs on the second visit A separate hashed asset downloads once and stream-compiles while it arrives, then survives later deploys in cache. A base64-inlined binary grows by about a third, cannot stream-compile, and is re-downloaded whenever the JavaScript chunk changes. separate hashed asset — 200 KB download compiles while it downloads next deploy: 0 bytes (cached) inlined as base64 — 266 KB inside the JS chunk download (+33%) decode compile strictly sequential next deploy: the whole chunk again, because a JS-only change invalidates the inlined binary too Inline only what is small enough that a second request would cost more than the bytes — roughly a few kilobytes.

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.

How do I load the module inside a Web Worker? Import the binary with ?url in the main thread, pass that string to the worker in its start message, and let the worker call WebAssembly.instantiateStreaming(fetch(url)). The worker runs outside the bundler’s module graph, so a bare import "./add.wasm" inside worker code resolves against the worker’s own URL and usually 404s in the built output. Passing the hashed URL keeps the reference correct in dev and in production, and the worker gets its own instance with its own linear memory, which is what you want for parallel compute.

Does vite-plugin-wasm run wasm-opt or change the binary? No. It resolves and loads the binary and generates the instantiation code around it; the bytes are emitted unchanged. Size optimization stays a build-time concern for the toolchain that produced the module — run wasm-opt in the crate’s release profile or as a build step before Vite ever sees the file.

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