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
.wasmto import: awasm-pack --target bundlerpackage, or a standalone.wasm - [ ]
vite-plugin-wasmandvite-plugin-top-level-awaitinstalled - [ ]
build.targetset toesnext(ores2022) so top-levelawaitcompiles
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
-
Install the plugins:
npm install -D vite-plugin-wasm vite-plugin-top-level-await -
Register them in
vite.config.tsand 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 }, }); -
Import with
?initwhen you have a standalone.wasmand want to supply animport 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 -
Or import with
?urlwhen 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)); -
Or import a
wasm-pack --target bundlerpackage directly — the plugin resolves the bare.wasmthe package references, and you call its generated entry:import init, { greet } from "my-wasm-pkg"; await init(); console.log(greet("Vite")); -
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 throwsNo loader is configured for ".wasm" files. Add the Wasm-bearing package tooptimizeDeps.excludeso the plugin, not esbuild, handles it. - Wrong MIME type in
preview.vite previewserves static files; if the host’s static layer does not map.wasmtoapplication/wasm,instantiateStreamingthrowsIncorrect response MIME type. Expected 'application/wasm'.Vite’s own preview sets it correctly, but a custom adapter may not — verify withcurl -Iand see the local development server configurations guide for fixing headers. - Top-level await needs
target: esnext. Leaving the default target producesTop-level await is not available in the configured target environment. Setbuild.targettoesnextores2022and ensureoptimizeDeps.esbuildOptions.targetmatches if you override it. - Inlining defeats streaming. A
.wasmunderassetsInlineLimitbecomes 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.
Related
- ESM bindings & module generation — generating the
.wasm,.mjs, and.d.tsthis build consumes. - Generating TypeScript types from Wasm — typing the imported module.
- Local development server configurations — serving
.wasmwith the right MIME type and headers.
← Back to ESM Bindings & Module Generation