Hot Reloading a Rust Wasm Crate During Development

This page answers one task: set up a development loop where saving a Rust source file rebuilds the WebAssembly package and refreshes the browser automatically, fast enough that you stop noticing it.

Prerequisites

  • [ ] Rust with the wasm32-unknown-unknown target, wasm-pack 0.12+ and cargo-watch (cargo install cargo-watch) or watchexec.
  • [ ] A web front end served by Vite, or any dev server that reloads on file changes.
  • [ ] A crate whose release build takes long enough to be annoying — which is most crates with real dependencies.

What “hot reload” can and cannot mean for Wasm

JavaScript frameworks have trained us to expect hot module replacement: edit a component, and the new code swaps in without losing the page’s state. WebAssembly cannot do that. A compiled module is immutable, and an instance’s state lives in its linear memory and globals, laid out by the old code. New code expects a different layout. There is no safe way to move a running instance onto a new module.

What you can have is an automatic full reload: the Rust edit triggers a rebuild, the dev server notices the new files, and the page reloads with a fresh instance. Done well, that loop takes two to five seconds for a typical crate, which is fast enough to feel interactive. Done badly — release profile, wasm-opt on every change, two watchers fighting — it takes forty seconds and nobody uses it.

The edit-to-browser loop Saving a Rust file wakes the watcher, which runs a fast dev-profile build and wasm-bindgen. The dev server sees the changed package files and tells the browser to reload, which instantiates the new module from scratch. save lib.rs editor writes file cargo watch debounced trigger wasm-pack --dev no wasm-opt, incremental Vite sees pkg/ change file watcher page reload fresh instance Every step except the build takes milliseconds; the dev profile settings decide whether the loop takes 3 seconds or 40.

Step 1 — make the dev build fast

Most of the loop’s time is the build, and most of that is avoidable in development. Three settings matter.

Use --dev with wasm-pack. It builds the dev profile and skips wasm-opt entirely. wasm-opt on a large module can take longer than compiling it, and it buys nothing while you are iterating.

Turn on a little optimization for dependencies but not for your own crate. Unoptimized Rust in Wasm can be painfully slow at runtime — a hundred times slower than release for numeric code — which makes testing the feature tedious. Optimizing dependencies costs nothing per edit, because they only rebuild when they change:

# Cargo.toml
[profile.dev]
opt-level = 1            # your crate: quick to build, usable at runtime

[profile.dev.package."*"]
opt-level = 3            # dependencies: built once, fast forever
debug = false

Keep incremental compilation on, which is the default for the dev profile. With it, a one-line change to your crate recompiles only the affected codegen units.

Step 2 — watch only the Rust sources

Run the watcher in its own terminal, watching only the directories that should trigger a rebuild:

cargo watch \
  -w crates/app/src -w crates/app/Cargo.toml \
  -d 0.3 \
  -s "wasm-pack build crates/app --dev --target web --out-dir ../../web/pkg"

The -w flags matter more than they look. Without them, cargo watch watches the whole crate directory — including pkg/ if it is inside the crate, and target/. Then every build writes files that trigger another build, and the loop never settles. The -d 0.3 debounce coalesces the burst of file events an editor produces when it saves several files at once.

Step 3 — let the dev server reload on package changes

Point the dev server at the generated package so a rebuild produces a reload. With wasm-pack’s web target and the package written into the web project’s own directory, Vite watches it by default. If the package lives in node_modules through a workspace link, un-ignore it as described in configuring the Vite dev server for Wasm.

// vite.config.js — package inside node_modules via workspace link
export default {
  optimizeDeps: { exclude: ["@acme/app"] },
  server: { watch: { ignored: ["!**/node_modules/@acme/app/**"] } },
};

When wasm-pack rewrites app_bg.wasm and app.js, Vite sees the change to a module the page imports and sends a full-reload message over its WebSocket. You will see page reload pkg/app.js in the terminal.

Step 4 — stop the double reload

wasm-pack writes several files: the .wasm, the JavaScript glue, TypeScript declarations and a package.json. The dev server may notice the first one before the others are written and reload against a half-written package — then reload again a moment later. The symptom is a flash of an error followed by a working page, or occasionally a CompileError about an unexpected end of the module.

Two fixes work. Build into a temporary directory and swap it in atomically:

cargo watch -w crates/app/src -d 0.3 -s '
  wasm-pack build crates/app --dev --target web --out-dir ../../web/.pkg-next &&
  rm -rf web/pkg && mv web/.pkg-next web/pkg'

Or have the watcher touch a single trigger file after the build, and configure the dev server to reload only on that file. The atomic rename is simpler and also protects against a failed build leaving a broken package behind: if compilation fails, the old package stays in place and the page keeps working.

One edit, timed from save to working page Timeline of a single edit through the loop with the dev profile tuned. The debounce and the dev build dominate; the atomic swap, the reload and the instantiation take a few hundred milliseconds together. 0 ms save in editor 300 ms debounce ends 2,350 ms cargo + bindgen done 2,410 ms atomic swap of pkg/ 2,530 ms Vite sends reload 2,980 ms new instance ready

One more source of wasted time is a build that fails. With the atomic swap the old package stays in place, so the page keeps working — but you also need to notice the failure. Watch the watcher’s terminal, or add a desktop notification on non-zero exit, so a compile error does not leave you debugging the previous version and wondering why your change had no effect.

Step 5 — keep useful state across reloads

A full reload throws away application state, which is the main cost of not having true hot replacement. For iterating on, say, a physics step or an image filter, you usually want the same inputs back after every reload. Persist them on the JavaScript side, which the module does not own:

// main.js
import init, { Simulation } from "./pkg/app.js";
await init();

const saved = JSON.parse(sessionStorage.getItem("dev:sim") ?? "null");
const sim = saved ? Simulation.from_config(saved) : Simulation.default_config();

if (import.meta.env.DEV) {
  addEventListener("beforeunload", () =>
    sessionStorage.setItem("dev:sim", JSON.stringify(sim.config())));
}

sessionStorage survives reloads within the tab and is cleared when the tab closes, which is the right lifetime for development state. Guarding with import.meta.env.DEV keeps it out of production builds. Save the inputs, not the module’s memory: a memory snapshot from old code is meaningless to new code.

Expected output

With the watcher in one terminal and the dev server in another, a save produces:

[Running 'wasm-pack build crates/app --dev --target web --out-dir ../../web/.pkg-next && ...']
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
    Finished `dev` profile [optimized + debuginfo] target(s) in 1.84s
[INFO]: ✨   Done in 2.05s
[Finished running. Exit status: 0]
10:42:18 [vite] page reload pkg/app.js

Gotchas

  • The build loops forever. The watcher sees its own output. Restrict -w to source directories and keep pkg/ and target/ outside them.
  • Release-speed builds in the loop. wasm-pack build without --dev runs wasm-opt and the release profile. Keep release builds for a separate command.
  • The page shows old behaviour after reload. The browser served the old .wasm from cache. Disable the cache in DevTools while developing, or make sure the dev server sends Cache-Control: no-cache.
  • Debug assertions make the app unusably slow. Integer overflow checks and debug assertions run in the dev profile. Raise opt-level for your crate to 1 or 2 rather than switching to release.

Performance note

On a crate with about 160 dependencies, the same one-line edit took 38 s with wasm-pack build (release profile, wasm-opt -O), 9.6 s with --dev and default profile settings, and 2.1 s with --dev plus incremental builds and optimized dependencies. The runtime speed of the tuned dev build was within 3× of release for the simulation step, which kept the app usable while iterating.

Edit-to-package time for one line changed Rebuild time for the same one-line change under three configurations. Skipping wasm-opt and the release profile removes most of the time; incremental compilation and pre-optimized dependencies remove most of the rest. seconds from save to rebuilt package release + wasm-opt 38 s --dev, default profile 9.6 s --dev, tuned profile 2.1 s

Frequently Asked Questions

Can I keep the module instance and swap only JavaScript? Yes, for edits to JavaScript files: Vite’s normal hot replacement applies to them. Only Rust changes need a full reload.

Is Trunk better for this? Trunk has the watch-build-reload loop built in and is the simpler choice for an all-Rust front end. For a JavaScript app that uses a Rust package, the setup here fits better; see building a Rust Wasm app with Trunk.

Does cargo watch work on Windows? Yes. Quote the -s command for PowerShell, or put the build in a just recipe and watch just dev-wasm instead, as in driving Wasm builds with a justfile.

What about debugging during the loop? The dev build keeps names and debug info, so breakpoints in Rust source keep working after each reload. See debugging Wasm from VS Code.

← Back to Local Development Server Configurations