Compile Rust to WebAssembly and run it in the browser

You can compile Rust to WebAssembly with wasm-pack and wasm-bindgen . The output is a .wasm binary plus JavaScript glue that runs in any modern browser at near-native speed. The toolchain is stable and needs no nightly Rust. You can call the module from plain JavaScript with no framework.

This guide walks the full pipeline: installing the tools, writing a Wasm module in Rust, loading it in the browser, shrinking the output for production, and debugging when things break.

Why Rust for WebAssembly

WebAssembly is past the experimental phase and running in production. Figma uses it for their rendering engine, where switching from asm.js to Wasm cut load time by 3x at any document size. Cloudflare Workers runs Wasm at the edge, and 1Password ’s browser extension relies on it for crypto. Databases run this way too: a WASM build is how you get SQLite running at the edge and inside the browser through OPFS.

Rust fits Wasm well because of its memory model. There’s no garbage collector and no runtime to bundle. So Rust ships tight binaries, often 50-200KB after a size pass. Go via TinyGo carries a runtime that adds weight. C/C++ via Emscripten works, but it lacks Rust’s safety guarantees. Zig is another option with similar goals.

Benchmark data from Ecostack’s comparison shows the gap:

LanguageBinary Size (total)Sort Benchmark (100K items, 500 copies)
Rust74 KB2,982 ms
AssemblyScript4.7 KB6,405 ms
TinyGo37 KB9,717 ms
JavaScript (typed arrays)N/A4,904 ms

Rust runs about 2x faster than AssemblyScript and 3x faster than TinyGo on CPU-bound work. AssemblyScript wins on binary size if size is your top concern. But for compute-heavy work like image processing, crypto, physics, codecs, or data transforms, Rust is the pragmatic pick. This in-browser image vectorizer runs the same stack: it compiles the VTracer crate to Wasm and traces images to vectors entirely client-side.

WebAssembly benchmark comparison across browsers showing Rust, AssemblyScript, TinyGo, and JavaScript performance
Wasm runtime benchmarks across Firefox, Edge, and Chrome
Image: Ecostack

The wasm32-unknown-unknown target has Tier 2 support on stable Rust, so it works out of the box with no nightly toolchain and no feature flags.

Setting up the toolchain

Getting the pipeline working takes about five minutes.

Install Rust if you haven’t already:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Add the Wasm compile target:

rustup target add wasm32-unknown-unknown

Install wasm-pack:

cargo install wasm-pack

wasm-pack wraps cargo build, runs wasm-bindgen to make the JavaScript bindings, and writes an npm-style package, so one command covers the whole flow.

wasm-pack compiling a Rust crate to a Wasm package with JavaScript bindings

Install wasm-opt (optional, for production optimization):

wasm-opt ships with Binaryen . Install it via your package manager (apt install binaryen, brew install binaryen, etc.). It runs Wasm-specific size and speed passes that rustc doesn’t do.

Create the project:

cargo init --lib my-wasm-lib
cd my-wasm-lib

Edit Cargo.toml to set the crate type and add wasm-bindgen:

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

[dependencies]
wasm-bindgen = "0.2"
pulldown-cmark = "0.12"

The cdylib crate type tells Rust to build a dynamic library fit for Wasm. The wasm-bindgen crate (now at 0.2.117) writes JavaScript and TypeScript bindings for your exported functions and types.

Your project structure looks like this:

my-wasm-lib/
├── Cargo.toml
├── src/
│   └── lib.rs

Writing your first Wasm module

A practical example: a Markdown-to-HTML converter that runs in the browser. This uses pulldown-cmark , a fast CommonMark parser written in Rust.

Edit src/lib.rs:

use wasm_bindgen::prelude::*;
use pulldown_cmark::{Parser, html};

#[wasm_bindgen]
pub fn convert_markdown(input: &str) -> String {
    let parser = Parser::new(input);
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    html_output
}

The #[wasm_bindgen] attribute is the key piece. It tells wasm-bindgen to write the JavaScript glue that converts types across the Wasm boundary. Strings, for example, must be copied between Rust’s linear memory and JavaScript’s managed heap. wasm-bindgen does this for you.

For structured data past strings and numbers, use serde with serde-wasm-bindgen to pass JSON across the boundary:

use serde::{Serialize, Deserialize};
use serde_wasm_bindgen;

#[derive(Serialize, Deserialize)]
pub struct ConvertOptions {
    pub smart_punctuation: bool,
    pub heading_offset: u8,
}

#[wasm_bindgen]
pub fn convert_with_options(input: &str, options: JsValue) -> Result<String, JsError> {
    let opts: ConvertOptions = serde_wasm_bindgen::from_value(options)?;
    // use opts.smart_punctuation, opts.heading_offset, etc.
    Ok(convert_markdown(input))
}

Build it:

wasm-pack build --target web

The --target web flag emits output that works with native ES modules, so no bundler is required. Use --target bundler if you’re pairing this with Vite or webpack.

After building, wasm-pack creates a pkg/ directory containing:

  • my_wasm_lib_bg.wasm - the compiled WebAssembly binary
  • my_wasm_lib.js - JavaScript glue code with ES module exports
  • my_wasm_lib.d.ts - TypeScript type definitions
  • package.json - npm package metadata
Diagram showing the Rust-to-WebAssembly build pipeline from source through wasm-pack to browser, with optional wasm-opt optimization

Loading Wasm in the browser

You load the built module from an HTML page. With native ES modules and no bundler:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Wasm Markdown Converter</title>
</head>
<body>
    <textarea id="input" rows="10" cols="60">## Hello from Wasm

This is **Markdown** rendered by *Rust*.</textarea>
    <div id="output"></div>

    <script type="module">
        import init, { convert_markdown } from './pkg/my_wasm_lib.js';

        async function run() {
            await init();  // fetches and compiles the .wasm file
            
            const input = document.getElementById('input');
            const output = document.getElementById('output');
            
            function render() {
                output.innerHTML = convert_markdown(input.value);
            }
            
            input.addEventListener('input', render);
            render();
        }

        run();
    </script>
</body>
</html>

The init() call fetches the .wasm file, compiles it, and starts the module. It must finish before you call any exported functions. After that, exported Rust functions like convert_markdown act like plain JavaScript ones. The wasm-bindgen glue does all the type marshaling.

The browser caches the compiled Wasm module. So first load pays compile cost, but later visits are near-instant. Serve the .wasm file with the application/wasm MIME type. Most modern HTTP servers do this by default. For local dev work, python -m http.server is fine.

Rust panics turn into JavaScript exceptions by default, but the messages are cryptic. Add the console_error_panic_hook crate for readable stack traces:

[dependencies]
console_error_panic_hook = "0.1"
#[wasm_bindgen(start)]
fn main() {
    console_error_panic_hook::set_once();
}

Using Wasm with a bundler

Not every team wants the no-bundler route. With Vite, the setup is simple:

wasm-pack build --target bundler
npm init -y
npm install ./pkg
npm install -D vite

Then import the module in your JavaScript:

import init, { convert_markdown } from 'my-wasm-lib';

await init();
convert_markdown("# Hello");

Vite handles .wasm file loading and MIME type setup for you. The vite-plugin-wasm plugin can go a step further and drop the init() call.

Optimizing for production

The debug build is bigger than it needs to be. A Markdown converter can hit 800KB in debug mode. A few passes shrink that by 10x.

Start with a release build:

wasm-pack build --release

This turns on opt-level = 3 in rustc. The binary drops to about 180KB.

Then add these Cargo.toml tweaks:

[profile.release]
lto = true          # link-time optimization across all crates
codegen-units = 1   # slower compilation, better optimization
strip = true        # remove debug symbols
opt-level = "z"     # optimize for size instead of speed

This often brings the binary down to about 95KB.

Now run wasm-opt for Wasm-specific cleanup:

wasm-opt -Oz -o optimized.wasm pkg/my_wasm_lib_bg.wasm

wasm-opt strips dead code, merges duplicate functions, and folds constants. After this, expect about 80-90KB.

With gzip on the server, the wire size drops to about 35KB. The full path:

StageSize
Debug build~800 KB
Release build~180 KB
Cargo.toml tweaks~95 KB
wasm-opt -Oz~80 KB
Gzipped~35 KB

Don’t block page render on Wasm startup. Load the module after the DOM is ready, or defer it until first use:

let wasmReady;

async function getConverter() {
    if (!wasmReady) {
        const wasm = await import('./pkg/my_wasm_lib.js');
        await wasm.default();
        wasmReady = wasm;
    }
    return wasmReady;
}

WebAssembly.instantiateStreaming() compiles the Wasm module while it’s still downloading, which cuts time-to-interactive. wasm-bindgen uses this by default when the browser supports it.

Async Rust functions in Wasm

The wasm-bindgen-futures crate bridges Rust’s async/await with JavaScript Promises. Use it when your Wasm module needs to do async work, like fetching data or waiting on timers.

use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, Response};

#[wasm_bindgen]
pub async fn fetch_data(url: &str) -> Result<JsValue, JsValue> {
    let mut opts = RequestInit::new();
    opts.method("GET");
    
    let request = Request::new_with_str_and_init(url, &opts)?;
    let window = web_sys::window().unwrap();
    let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
    let resp: Response = resp_value.dyn_into()?;
    let json = JsFuture::from(resp.json()?).await?;
    Ok(json)
}

From JavaScript, this function returns a plain Promise. The caller never has to know it’s Rust under the hood.

Debugging and testing

Chrome now supports source-level Wasm debugging with DWARF info. Build with wasm-pack build --dev to keep debug symbols. Then you can set breakpoints in Rust source files right in Chrome DevTools.

Chrome DevTools showing WebAssembly source-level debugging with DWARF information, displaying original source code and breakpoints
Chrome DevTools debugging a WebAssembly module with DWARF source maps
Image: Chrome for Developers Blog

wasm-pack also includes a test runner:

wasm-pack test --chrome --headless

Write tests using the wasm_bindgen_test attribute:

#[cfg(test)]
mod tests {
    use super::*;
    use wasm_bindgen_test::*;

    wasm_bindgen_test_configure!(run_in_browser);

    #[wasm_bindgen_test]
    fn test_markdown_conversion() {
        let result = convert_markdown("**bold**");
        assert!(result.contains("<strong>bold</strong>"));
    }
}

Test pure Rust logic with cargo test. It’s fast and needs no browser. Save wasm_bindgen_test for code that touches the JavaScript boundary or browser APIs.

A few common pitfalls to know:

  • Wasm’s default stack is 1MB. Deep recursion will crash. Bump it with -C link-arg=-zstack-size=2097152, or switch to iteration.
  • JavaScript handles passed to Rust via JsValue aren’t auto-freed. Drop them by hand, or use scoped handles to avoid leaks.
  • Loading a .wasm file from a different origin trips CORS rules. Serve it from the same origin, or set the right headers.
  • If the server omits the application/wasm MIME type, some browsers will refuse to compile the module via streaming.

WebAssembly threads

For CPU-heavy work that wants parallelism, Wasm supports threads via SharedArrayBuffer and Web Workers. Each worker runs its own Wasm instance and shares memory through the shared buffer.

The wasm-bindgen-rayon crate lets you use Rayon ’s parallel iterators in Wasm. Build with these flags:

RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \
  cargo build --target wasm32-unknown-unknown --release -Z build-std=std,panic_abort

There are deploy rules: your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers to turn on SharedArrayBuffer. Not every host supports this.

Threading is worth the effort for jobs like image processing or ray tracing, where parallelism can deliver 2-4x speedups. For most cases like DOM work, string handling, or small compute, single-threaded Wasm is fast enough.

What’s coming

Past the browser, WASI Preview 2 (WASI 0.2.0) hit stable in early 2024. It gives a standard system interface for server-side Wasm. WASI Preview 3, due around early 2026, adds native async to the Component Model.

The Component Model aims to make Wasm modules composable across languages. A Rust component could call a Python component directly, with no JavaScript in between. It’s still in the W3C proposal stage and not yet in browsers. But runtimes like Wasmtime already have full support.

For browser Wasm, today’s wasm-pack plus wasm-bindgen stack is production-ready. Start with one small, compute-heavy function and measure whether the speed gain pays for the extra build steps before you expand.