Configuring the Vite Dev Server for Wasm
This page answers one task: configure Vite’s development server so a WebAssembly module — from wasm-pack, Emscripten or a hand-built file — loads, reloads and, if needed, runs with threads, without surprises that only appear in dev mode.
Prerequisites
- [ ] Vite 5 or newer (
npm create vite@latestfor a fresh project). - [ ] A Wasm package, either inside the project or linked from a workspace as in structuring a monorepo with Rust Wasm and a JS app.
- [ ] Optionally
vite-plugin-wasmandvite-plugin-top-level-await, if you use wasm-pack’sbundlertarget.
Why dev mode behaves differently from the build
Vite runs in two quite different modes. vite build bundles with Rollup and produces static files; there is
nothing exotic about how a .wasm file is emitted there. vite dev does not bundle at all. It serves your source
files as native ES modules over HTTP, transforms them on request, and pre-bundles dependencies from
node_modules with esbuild into a cache directory for speed.
Each of those dev-mode behaviours touches WebAssembly. Serving source files means the file-system access rules
decide whether a .wasm outside the project root can be served at all. Pre-bundling dependencies means a package
that loads its binary with new URL("x.wasm", import.meta.url) gets moved into .vite/deps/ while its binary
stays behind — so the URL points at a file that is not there. And the dev server sends its own response headers,
which do not include the cross-origin isolation headers threaded modules require.
Step 1 — exclude Wasm packages from pre-bundling
The most common dev-only failure is a 404 for the .wasm file, or a module that loads stale code after a rebuild.
Both come from pre-bundling. Exclude the package:
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
optimizeDeps: {
exclude: ["@acme/geometry", "@ffmpeg/ffmpeg", "@sqlite.org/sqlite-wasm"],
},
});
Excluded packages are served from their real location in node_modules, so import.meta.url inside them still
points next to their binary. Several popular Wasm libraries document this exclusion in their README; it is worth
applying to any package that ships a .wasm file, whether or not it says so.
Step 2 — allow files from outside the project root
Vite refuses to serve files outside the workspace root, with an error page saying the request is outside the
allow list. In a monorepo where the Wasm package lives in a sibling directory, or when a pkg/ directory is linked
from elsewhere, widen the list explicitly:
import { searchForWorkspaceRoot } from "vite";
export default defineConfig({
server: {
fs: {
allow: [
searchForWorkspaceRoot(process.cwd()), // the monorepo root
"../crates/geometry/pkg", // a specific extra directory
],
},
},
});
Add only what you need. The allow list protects against a dev server that is accidentally reachable from the network serving arbitrary files from your machine.
Step 3 — send isolation headers if you use threads
Threaded builds need SharedArrayBuffer, which needs cross-origin isolation. Set the headers for both the dev
server and the preview server so you are testing the same conditions in each:
const isolation = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
export default defineConfig({
server: { headers: isolation },
preview: { headers: isolation },
});
Check in the console that self.crossOriginIsolated is true. If it is not, a resource on the page is missing
Cross-Origin-Resource-Policy or the page was opened from a URL the headers do not apply to. The underlying rules
are in configuring COOP/COEP headers for SharedArrayBuffer.
Step 4 — configure workers to be modules
Wasm work usually belongs in a worker. Vite supports new Worker(new URL("./w.js", import.meta.url), { type: "module" }) in dev and build, but the production worker format defaults to iife, which cannot use
import.meta.url or top-level await. Make it a module in both modes:
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
worker: {
format: "es",
plugins: () => [wasm(), topLevelAwait()],
},
});
The worker gets its own plugin list; plugins on the main config do not apply inside workers. Forgetting this is why a module that loads fine on the main thread fails inside a worker with an unknown-file-type error.
Step 5 — pick up rebuilt binaries automatically
When a Rust or C build rewrites the package while Vite is running, the dev server needs to notice. Files under
node_modules are not watched by default. Un-ignore your own scope:
export default defineConfig({
server: {
watch: { ignored: ["!**/node_modules/@acme/**"] },
},
});
A changed .wasm triggers a full page reload rather than hot module replacement — there is no way to swap a
running instance’s code — which is usually what you want. The full edit-to-browser loop, with the Rust watcher on
the other side, is described in
hot reloading a Rust Wasm crate during development.
Matching symptoms to settings
Most Vite-plus-Wasm problems present as one of a handful of symptoms, and each symptom maps to exactly one of the settings above. When something breaks, start from the symptom rather than from the configuration file.
Two of those rows deserve emphasis because their error messages mislead. A pre-bundled package often fails not
with a 404 but with a CompileError saying the magic number is wrong — because the dev server answered the
request for a missing file with the HTML of the app’s index page, and the engine tried to compile HTML. And a
missing allow-list entry looks like a network failure in the console; the explanatory error page is only visible
if you open the failed request in the Network panel. In both cases the fix is a one-line config change, but
finding which line takes longer than it should if you trust the console message.
The whole configuration
// vite.config.js
import { defineConfig, searchForWorkspaceRoot } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
const isolation = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
optimizeDeps: { exclude: ["@acme/geometry"] },
server: {
headers: isolation,
fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
watch: { ignored: ["!**/node_modules/@acme/**"] },
},
preview: { headers: isolation },
worker: { format: "es", plugins: () => [wasm(), topLevelAwait()] },
});
Keep this file under review like any other code. Each option exists for a specific package or feature, and a short comment next to it — which package needed the exclusion, which feature needs the headers — saves the next person from deleting a line that looks unnecessary until the day it is not.
Expected output
With the dev server running, three quick checks in the browser console confirm the setup:
crossOriginIsolated; // true (only if you set the headers)
(await fetch("/node_modules/@acme/geometry/geometry_bg.wasm", { method: "HEAD" }))
.headers.get("content-type"); // "application/wasm"
performance.getEntriesByType("resource")
.filter((e) => e.name.endsWith(".wasm")).length; // 1 — loaded once, not twice
In the terminal, saving a Rust file produces a rebuild in the watcher’s window and a page reload line from Vite.
Gotchas
- 404 for the
.wasmonly in dev. The package was pre-bundled. Add it tooptimizeDeps.excludeand restart with--forceto clear the dependency cache. The request url ... is outside of Vite serving allow list. The binary is outside the root. Add its directory toserver.fs.allow.- Works in dev, fails in the worker in production. Worker format is
iifeor the worker has no Wasm plugin. Setworker.format: "es"and give the worker its own plugins. - Isolated in dev, not in preview. Headers were set on
serverbut notpreview. Set both.
Performance note
Excluding a large Wasm package from pre-bundling does not slow the dev server noticeably: the package’s JavaScript is small, and the binary was never pre-bundled anyway. On a project with ffmpeg.wasm and SQLite, cold dev-server start was 1.9 s with both excluded and 1.7 s with both pre-bundled — and only the excluded configuration loaded correctly.
Frequently Asked Questions
Do I need vite-plugin-wasm for wasm-pack’s web target?
No. The web target loads its binary with fetch and import.meta.url, which Vite handles natively. The plugin
is needed for the bundler target, which imports the .wasm file directly.
Why does the module reload the whole page instead of hot-updating? A WebAssembly instance cannot be patched in place, and modules usually hold state in linear memory. A full reload is the only correct update.
Should these settings go in production config too?
The isolation headers belong on the production server as well, but server.* options only affect Vite’s own
servers. Configure your hosting separately.
Can I use vite-plugin-wasm and still exclude the package from pre-bundling?
Yes, and for packages that import their .wasm directly you usually need both: the plugin teaches Vite what a
.wasm import means, and the exclusion keeps the package’s files where its relative references expect them.
Does Vite’s dev server compress responses?
No; it serves files uncompressed for speed. Measure transfer sizes with vite preview or your real server, not
in dev.
Related
- Bundling Wasm ESM with Vite — the production build side.
- Serving Wasm with the correct MIME type locally — the header Vite already gets right.
- Loading Wasm in a Web Worker with ESM — the worker pattern configured in step 4.
- Using Wasm in a SvelteKit app — a framework built on these Vite settings.
← Back to Local Development Server Configurations