How to Deploy Rust WASM Applications

Web
Deploy Rust WASM apps by adding the wasm32-unknown-unknown target, building in release mode, and serving the output file to the browser.

The engine and the car

You spent weeks optimizing a pathfinding algorithm in Rust. It runs in milliseconds. Now you want to drop it into a web app so your users can see the magic. You compile, grab the .wasm file, and drop it into an HTML script tag. The browser loads the file, stares at it, and throws a cryptic error about "indirect calls" or "memory not found." You have the binary, but the browser doesn't know how to talk to it.

Rust and JavaScript live in different worlds. Rust manages its own memory and expects a specific calling convention. JavaScript uses garbage collection and has its own type system. WebAssembly bridges the gap, but it doesn't speak JavaScript directly. You need a transmission that connects the two.

The engine and the car

Think of WebAssembly as a high-performance engine block. It is incredibly efficient at doing math and processing data. But it has no steering wheel, no radio, and no connection to the dashboard. JavaScript is the rest of the car. It handles the UI, the network requests, and the user input.

To make the engine work, you need a transmission. In Rust, that transmission is wasm-bindgen. It generates the glue code that lets JavaScript call your Rust functions and pass data back and forth without manual memory management. wasm-bindgen converts Rust types like String and Vec into JavaScript types like string and Array. It allocates a shared memory buffer so both sides can read and write data safely.

The community standard for orchestrating this process is wasm-pack. It wraps wasm-bindgen, runs the Rust compiler, optimizes the binary size, and generates the JavaScript wrapper. You rarely need to call wasm-bindgen directly. wasm-pack is the convention because it handles the toolchain configuration and optimization steps that are easy to get wrong.

The target triple: wasm32-unknown-unknown

Rust uses target triples to describe the platform you are compiling for. The format is architecture-vendor-os. For WebAssembly in the browser, the target is wasm32-unknown-unknown.

The wasm32 part specifies the WebAssembly architecture with 32-bit pointers. The first unknown means there is no specific vendor. The second unknown means there is no operating system. This target produces a raw WebAssembly binary that expects the JavaScript engine to provide the runtime environment. It has no access to the file system, no standard library I/O, and no threading support.

This is the correct target for browser deployment. If you see wasm32-wasip1, that is a different target for server-side runtimes like Wasmtime or Wasmer. The wasip1 target includes support for system calls, file access, and standard I/O. Using the wrong target breaks the build or produces a binary that the runtime cannot load.

Install the target with rustup:

rustup target add wasm32-unknown-unknown

Building with wasm-pack

Start with a library crate. Binary crates with main functions do not work well for WebAssembly because the browser needs to import functions, not run a program. Create a new library and add wasm-bindgen as a dependency.

[package]
name = "wasm-demo"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

The crate-type = ["cdylib"] line is essential. It tells Rust to compile the library as a dynamic library, which is the format required for WebAssembly. Without this, cargo build produces a static archive that cannot be loaded by the browser.

Write a function and mark it for export:

use wasm_bindgen::prelude::*;

/// This attribute marks the function for export.
/// Without it, wasm-bindgen ignores the function,
/// and JavaScript cannot see it.
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

Build the project with wasm-pack:

wasm-pack build --target web

The --target web flag tells wasm-pack to generate output suitable for loading directly in a browser. It creates a pkg directory containing the .wasm file, a .js wrapper, and TypeScript definitions. The .js file is the entry point. It handles loading the .wasm binary and setting up the memory.

Do not import the .wasm file directly in your frontend. Import the .js file. The JavaScript wrapper manages the module instantiation and exports the Rust functions as JavaScript functions.

Loading the module

In your HTML, load the generated JavaScript file. Use instantiateStreaming for the best performance. It parses the WebAssembly bytes while they are still downloading, reducing startup time.

<!DOCTYPE html>
<html>
<head>
    <title>Rust WASM Demo</title>
</head>
<body>
    <script type="module">
        // instantiateStreaming fetches and compiles the module in one network round-trip.
        // This is faster than fetching the bytes and then compiling them separately.
        import init from './pkg/wasm_demo.js';

        init().then(instance => {
            // The instance.exports object contains the exported functions.
            const result = instance.exports.add(10, 20);
            console.log('Result:', result);
        });
    </script>
</body>
</html>

The init function returned by the generated JavaScript file handles all the setup. It loads the .wasm file, allocates memory, and returns a promise that resolves to the module instance. You call the exported functions directly from the instance.

Talking to the browser

Rust code in WebAssembly cannot access the DOM or browser APIs directly. You need bindings. The web-sys crate provides safe Rust wrappers for the Web API. It is generated from the Web IDL specifications and keeps type safety.

Add web-sys to your dependencies:

[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["console", "Window", "Document", "Element"] }

The features list is required. web-sys uses feature flags to control code size. Only the features you enable are compiled into your binary. This keeps the size small.

Use web-sys to interact with the console or DOM:

use wasm_bindgen::prelude::*;
use web_sys::console;

/// Log a message to the browser console.
/// web_sys provides type-safe access to browser APIs.
#[wasm_bindgen]
pub fn log_message(msg: &str) {
    // JsValue is the bridge type between Rust and JS.
    // It handles the conversion automatically.
    console::log_1(&JsValue::from_str(msg));
}

Convention aside: web-sys functions often take &JsValue or Option<&JsValue>. This reflects the dynamic nature of JavaScript. The compiler ensures you pass the right types, but you still need to handle None cases where the API might return null.

Pitfalls and gotchas

Binary size is the enemy in WebAssembly. Every kilobyte adds latency for your users. The Rust standard library is large. Functions like println! pull in the entire I/O stack, which can bloat your binary by hundreds of kilobytes. Avoid std::io and std::fs in browser targets. They do not work and they increase size.

Use web_sys::console::log for debugging. It is lightweight and integrates with the browser developer tools. For production logging, use a crate like tracing-wasm that sends logs to the console or a remote endpoint.

If you try to return a custom struct from a function without marking it, the compiler rejects you with E0277 (trait bound not satisfied). The struct needs #[wasm_bindgen] to generate the conversion logic. wasm-bindgen only knows how to handle types that implement IntoWasmAbi and FromWasmAbi. Primitive types and String work out of the box. Custom types need the attribute.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Point {
    x: i32,
    y: i32,
}

#[wasm_bindgen]
impl Point {
    #[wasm_bindgen(constructor)]
    pub fn new(x: i32, y: i32) -> Point {
        Point { x, y }
    }

    pub fn distance(&self, other: &Point) -> f64 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;
        (dx * dx + dy * dy) as f64
    }
}

The #[wasm_bindgen(constructor)] attribute marks a function as the constructor. JavaScript can then call new Point(10, 20). Without it, the function is just a static method.

Memory access errors are common when you return references that escape the function boundary. WebAssembly has a linear memory model. Rust references are pointers into that memory. If you return a reference to a local variable, the memory is freed when the function returns. JavaScript then accesses invalid memory. wasm-bindgen prevents many of these errors at compile time, but complex interactions can still cause issues.

Treat binary size as a first-class metric. Every kilobyte adds latency for your users.

Decision: choosing your toolchain

Use wasm-pack for browser applications when you need a complete build pipeline that compiles Rust, generates JavaScript glue code, and optimizes the binary size in one command.

Use wasm-pack --target bundler when your frontend project uses Webpack, Vite, or Rollup and requires ES module exports that integrate with your asset pipeline.

Use wasm-pack --target web when you are loading the WebAssembly module directly in a browser via a script tag without a bundler.

Use wasm-bindgen manually when you are constructing a custom build tool or need precise control over the generated JavaScript bindings.

Use wasm32-unknown-unknown when deploying to the browser where the JavaScript engine provides the runtime environment.

Use wasm32-wasip1 when deploying to server-side runtimes like Wasmtime or Wasmer that support system calls and file access.

Match the target to the runtime. The browser expects unknown-unknown. The server expects wasip1. Mixing them breaks the build.

Where to go next