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-unknowntarget,wasm-pack0.12+ andcargo-watch(cargo install cargo-watch) orwatchexec. - [ ] 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.
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 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
-wto source directories and keeppkg/andtarget/outside them. - Release-speed builds in the loop.
wasm-pack buildwithout--devrunswasm-optand the release profile. Keep release builds for a separate command. - The page shows old behaviour after reload. The browser served the old
.wasmfrom cache. Disable the cache in DevTools while developing, or make sure the dev server sendsCache-Control: no-cache. - Debug assertions make the app unusably slow. Integer overflow checks and debug assertions run in the dev
profile. Raise
opt-levelfor 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.
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.
Related
- Best practices for wasm-pack configuration — the profiles used in the loop.
- Structuring a monorepo with Rust Wasm and a JS app — where the package lives in a larger repo.
- Shrinking Rust Wasm with Cargo profiles — the release-side counterpart to these dev settings.
- Writing a minimal Node dev server for Wasm — a reload-capable server without Vite.
← Back to Local Development Server Configurations