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@latest for 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-wasm and vite-plugin-top-level-await, if you use wasm-pack’s bundler target.

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.

Where a Wasm package can break in Vite's dev server A request for the app passes through dependency pre-bundling, where packages using import.meta.url lose track of their binary; the file-system allow list, which blocks files outside the root; and the response headers, which lack cross-origin isolation by default. browser requests module native ESM, no bundle optimizeDeps pre-bundle moves JS, not the .wasm server.fs.allow blocks files outside root response headers no COOP/COEP by default module loads after all three are set

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.

Dev-server symptoms and the setting that fixes each Five common failures when loading WebAssembly through Vite's dev server, mapped to the configuration option responsible for each. symptom in dev cause setting 404 for the .wasm file dependency pre-bundled optimizeDeps.exclude outside of serving allow list file outside root server.fs.allow SharedArrayBuffer is not defined page not isolated server.headers worker fails, main thread fine iife worker, no plugins worker.format + plugins old code after Rust rebuild node_modules not watched server.watch.ignored

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 .wasm only in dev. The package was pre-bundled. Add it to optimizeDeps.exclude and restart with --force to clear the dependency cache.
  • The request url ... is outside of Vite serving allow list. The binary is outside the root. Add its directory to server.fs.allow.
  • Works in dev, fails in the worker in production. Worker format is iife or the worker has no Wasm plugin. Set worker.format: "es" and give the worker its own plugins.
  • Isolated in dev, not in preview. Headers were set on server but not preview. 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.

Dev server cold start with and without Wasm package exclusions Cold start of Vite's dev server for a project using two Wasm-heavy packages. Excluding them from dependency pre-bundling costs about 200 milliseconds and is the configuration that actually works. ms from vite dev to first page served both pre-bundled (broken .wasm URLs) 1,710 ms both excluded 1,920 ms excluded, warm dependency cache 640 ms

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.

← Back to Local Development Server Configurations