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.
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 -
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
?urlimport 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.
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. - The dev server and the built output disagree about paths. In dev, Vite serves your source tree,
so a hard-coded
/src/add.wasmresolves; after a build that file lives at/assets/add-4b7e0d.wasmand 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.wasmas a bug even when it works locally. optimizeDeps.excludeis 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
.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.
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.
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.
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