Skip to content

Rust and WebAssembly

WebAssembly (often shortened to Wasm) lets you run code written in languages like Rust inside a web browser, next to JavaScript. Think of it as a second engine in the browser: JavaScript still drives the page, but when you have heavy work — image filters, physics, parsing big files — you can hand it to fast, compiled Rust code.

On this page you’ll write a tiny Rust library, compile it to Wasm, and call it from a web page. Nothing here runs in the Rust Playground (it needs a browser and extra tools), so most examples are plain code blocks for your own machine. The pure-Rust parts, though, you can try right away.

WebAssembly is a compact binary format for programs. Every major browser can run it, at close to native speed, inside the same security sandbox as JavaScript. It isn’t only for browsers: servers and edge platforms run Wasm too.

Rust is one of the most popular languages for Wasm, for a few reasons:

  • No garbage collector or big runtime. Rust doesn’t need to ship extra machinery with your code, so .wasm files can be small.
  • Great tools. wasm-bindgen writes the glue code between Rust and JavaScript for you, and wasm-pack builds everything in one command.
  • Safety and speed. You get the same memory safety and performance you’d get on a desktop.

First, tell Rust it can compile for the Wasm target. A target is “the kind of machine the code will run on”; wasm32-unknown-unknown means “32-bit Wasm, no particular operating system”:

Terminal window
rustup target add wasm32-unknown-unknown

Next, install wasm-pack. It compiles your crate, runs wasm-bindgen, and puts everything you need into one folder. Either use the official installer, or build it with Cargo:

Terminal window
# Linux / macOS installer from the wasm-pack website
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# or, on any system with Rust installed
cargo install wasm-pack --locked

A Wasm module is a library, not a program with main. Create one:

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

Then edit Cargo.toml. The crate-type line tells Cargo to produce a .wasm-friendly library (cdylib), and wasm-bindgen is the crate that connects Rust and JavaScript:

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

Now write two functions in src/lib.rs. The #[wasm_bindgen] attribute marks each one as “callable from JavaScript”:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}

That’s all the Rust you need. Notice they’re ordinary functions: wasm-bindgen takes care of turning a JavaScript string into a &str and the returned String back into a JavaScript string.

Here’s a habit that saves a lot of time: keep the real work in plain Rust functions, and test them on your normal computer with cargo test or a quick main. Debugging in a browser is slower than debugging in a terminal. The same logic, without the attribute, runs anywhere:

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

Output:

Hello, Ferris! This string was built in Rust.
2 + 3 = 5
▶ Try it Yourself

Once the logic is right, the #[wasm_bindgen] wrapper is just a thin door to the browser. (Tests marked #[test] also run natively with cargo test — see Testing.)

From the crate folder, run:

Terminal window
wasm-pack build --target web

--target web means “make files a browser can load directly with a <script type="module"> tag”, with no bundler like webpack needed. When we ran it, the new pkg/ folder contained:

pkg/
├── hello_wasm_bg.wasm the compiled Rust code
├── hello_wasm.js JavaScript glue that loads it and wraps each function
├── hello_wasm.d.ts TypeScript types for your functions
├── hello_wasm_bg.wasm.d.ts
└── package.json

The dash in hello-wasm became an underscore in the file names. By default wasm-pack build makes a release (optimized) build; add --dev for a faster, unoptimized one while you experiment.

Create index.html next to Cargo.toml (so pkg/ sits beside it):

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello from Rust</title>
</head>
<body>
<p id="message">Loading…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // download and start the .wasm file
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

The default export, init(), downloads the .wasm file and starts it. After that, add and greet are normal JavaScript functions.

Browsers won’t load modules from a file:// address, so you need a small local web server. If you have Python installed, this is the quickest:

Terminal window
python3 -m http.server 8080

Open http://localhost:8080 in your browser. When we did, the page showed:

Hello, Ferris! This string was built in Rust. 2 + 3 = 5

That sentence was built by Rust, running inside the browser. 🦀

What crosses the boundary cheaply (and what doesn’t)

Section titled “What crosses the boundary cheaply (and what doesn’t)”

Rust and JavaScript don’t share memory in the same way. The Wasm module has its own block of memory, and values have to travel between the two sides. Some trips are free; others cost a copy:

Type in Rust In JavaScript Cost
i32, u32, f64, bool number, boolean Cheap: passed directly
&str, String string A copy, plus converting UTF-16 to UTF-8 (or back)
&[u8], Vec<u8> Uint8Array A copy into (or out of) Wasm memory
&mut [u8] Uint8Array Copied in, then copied back after the call

So the golden rule is: make few calls that each do a lot of work. Calling Rust once to process a whole image is fast. Calling Rust a million times, once per pixel, spends most of its time crossing the boundary.

Here’s what that looks like. This function brightens a whole buffer of pixel values in one call:

use wasm_bindgen::prelude::*;
/// Make every pixel brighter. JS passes a Uint8Array.
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

From JavaScript, after await init(), you’d call brighten(pixels, 20) with a Uint8Array, and the array is updated in place. The loop itself is pure Rust, so you can test it natively:

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add stops at 255 instead of overflowing
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

Output:

[30, 120, 255]
▶ Try it Yourself

Notice 250 + 20 became 255, not a crash or a wrap-around: saturating_add stops at the maximum. That’s exactly what you want for colors.

Every byte of your .wasm file has to be downloaded before the page can use it. Some tips:

  • Always ship the release build. For our tiny example, wasm-pack build --dev produced a 58,591-byte .wasm file; the default release build was 21,530 bytes. (That’s from our machine with wasm-opt turned off — your numbers will vary.)
  • Let wasm-pack run wasm-opt. In release mode, wasm-pack runs Binaryen’s wasm-opt optimizer on the output by default, which usually shrinks it further.
  • Turn on compression on your server. Our 21,530-byte file was 8,464 bytes after gzip.
  • Try size-focused settings, then measure:
[profile.release]
opt-level = "z" # optimize for size ("s" is a milder version)
lto = true
codegen-units = 1

Be honest with yourself and measure. For our two-function example, these settings didn’t help — the file came out at 22,817 bytes, slightly bigger. They tend to pay off in larger crates. Things like format! and panic messages also pull in code, so a crate that avoids them can be much smaller.

A few examples, each with the company’s own write-up:

  • Figma compiles its C++ design editor to WebAssembly and reported that it cut load time by 3x. (Not Rust, but it shows what Wasm can do in a big app.)
  • Cloudflare Workers supports Rust through the workers-rs crate: your Rust code is compiled to Wasm and runs on Cloudflare’s servers around the world.
  • Shopify Functions are WebAssembly modules that customize stores, and Shopify recommends Rust as the most performant language for them.
  • 1Password’s browser extension moved its page filling to WebAssembly and reported it running at least twice as fast.

Forgetting crate-type = ["cdylib"]. Without it, wasm-pack stops right away. Happily, the error tells you exactly what to add:

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

Opening index.html by double-clicking it. The page stays on “Loading…”, and the browser console (in Chrome) says:

Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.

The fix: serve the folder with a local web server, like python3 -m http.server, and open the http://localhost address instead.

Calling a function before await init(). The Wasm module isn’t loaded yet, so the glue code has nothing to call. When we removed the init() line, Chrome showed Cannot read properties of undefined (reading '__wbindgen_free'). Always await init() first.

Calling a few Rust functions from JavaScript is a great start. If you want to build the whole web app in Rust, look at these frameworks. Both compile to Wasm and let you write components in Rust, a bit like React:

  • Leptos — a fast, modern framework that can also render pages on the server.
  • Yew — a mature, component-based framework.
  • WebAssembly is a fast, safe binary format that browsers (and servers) can run; Rust compiles to it well because it has no garbage collector.
  • Add the target with rustup target add wasm32-unknown-unknown, and install wasm-pack.
  • Mark functions with #[wasm_bindgen], set crate-type = ["cdylib", "rlib"], and build with wasm-pack build --target web.
  • In JavaScript, await init() first, then call your functions; serve the page over http://, not file://.
  • Numbers cross the boundary cheaply; strings and slices are copied — so make few, chunky calls.
  • Ship release builds with compression, and measure before trusting size tweaks.

Quick check

1. Which command adds the Rust target for browser WebAssembly?

2. What does the #[wasm_bindgen] attribute do?

3. Your page stays on "Loading…" and the console shows a CORS error for a file:/// address. What's the fix?

4. Which of these is cheapest to pass from JavaScript to Rust?

Score: 0 / 4