In the previous tutorial, we explored Rust for AI/ML. Now we learn WebAssembly (WASM) — how to run Rust code in the browser at near-native speed.
This is a conceptual tutorial. The code runs on your regular computer but demonstrates the exact patterns used in real Rust+WASM projects. You will understand how WASM works, how data flows between Rust and JavaScript, and how frameworks like Leptos build web apps entirely in Rust.
What is WebAssembly?
WebAssembly is a binary format that runs in web browsers. It’s not JavaScript. It’s a low-level virtual machine that runs alongside JavaScript in the browser.
Key facts:
- Fast — runs at near-native speed, much faster than JavaScript for computation
- Safe — runs in a sandbox, can’t access the system directly
- Portable — same binary runs in every browser (Chrome, Firefox, Safari, Edge)
- Language-agnostic — compiled from Rust, C, C++, Go, and others
The typical flow:
- Write code in Rust
- Compile to
.wasmbinary - Load the
.wasmfile in a web page - Call Rust functions from JavaScript (and vice versa)
Why Rust for WASM?
Rust is the best language for WASM because:
- No garbage collector — WASM doesn’t have one, and Rust doesn’t need one
- Small binaries — Rust WASM binaries are tiny (tens of KB)
- No runtime — no extra code needed to run
- Memory safety — no buffer overflows in your WASM module
- Great tooling — wasm-pack, wasm-bindgen, and trunk make development easy
Functions Exported to JavaScript
In real WASM, you mark functions with #[wasm_bindgen] to expose them to JavaScript. Here are the patterns:
Pure Computation
The most common WASM use case — heavy computation that’s too slow in JavaScript:
// In real WASM: #[wasm_bindgen]
fn fibonacci(n: u32) -> u64 {
if n <= 1 {
return n as u64;
}
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let temp = a + b;
a = b;
b = temp;
}
b
}
JavaScript calls fibonacci(40) and gets the result instantly. The same calculation in JavaScript is much slower.
String Processing
fn validate_email(email: &str) -> bool {
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
if local.is_empty() || domain.is_empty() {
return false;
}
if !domain.contains('.') {
return false;
}
true
}
fn format_number(n: i64) -> String {
let negative = n < 0;
let s = n.unsigned_abs().to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::new();
for (i, ch) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i) % 3 == 0 {
result.push(',');
}
result.push(*ch);
}
if negative { format!("-{}", result) } else { result }
}
Strings cross the WASM boundary through shared memory. wasm-bindgen handles the conversion automatically.
Module State
WASM modules can hold state. In real WASM, you’d use thread_local! or lazy_static for module-level state. JavaScript calls exported functions that read and modify this state:
struct WasmState {
counter: i32,
items: Vec<String>,
scores: HashMap<String, f64>,
}
impl WasmState {
fn increment(&mut self) -> i32 {
self.counter += 1;
self.counter
}
fn add_item(&mut self, item: String) -> usize {
self.items.push(item);
self.items.len()
}
fn top_scores(&self, n: usize) -> Vec<(String, f64)> {
let mut sorted: Vec<_> = self.scores.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
sorted.into_iter().take(n).collect()
}
}
From JavaScript, you’d call:
wasm.increment();
wasm.add_item("Task 1");
const scores = wasm.top_scores(5);
The state lives inside the WASM module’s memory. JavaScript can’t access it directly — only through the exported functions.
Virtual DOM
Web frameworks like Leptos build a virtual DOM in Rust. Here’s how it works:
#[derive(Debug, Clone, PartialEq)]
enum VNode {
Element {
tag: String,
attributes: Vec<(String, String)>,
children: Vec<VNode>,
},
Text(String),
}
With a builder pattern for creating nodes:
let page = VNode::element("div")
.attr("class", "container")
.child(VNode::element("h1")
.child(VNode::text("Hello, WASM!"))
.build())
.child(VNode::element("p")
.child(VNode::text("This is rendered by Rust."))
.build())
.build();
Rendering to HTML
fn render_html(&self) -> String {
match self {
VNode::Text(text) => html_escape(text),
VNode::Element { tag, attributes, children } => {
let attrs: String = attributes.iter()
.map(|(k, v)| format!(" {}=\"{}\"", k, html_escape(v)))
.collect();
if children.is_empty() {
format!("<{}{}/>", tag, attrs)
} else {
let inner: String = children.iter()
.map(|c| c.render_html()).collect();
format!("<{}{}>{}</{}>", tag, attrs, inner, tag)
}
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
This produces clean HTML. In a real framework, the virtual DOM is applied to the actual DOM efficiently using diffing.
Virtual DOM Diffing
When state changes, frameworks compare the old and new virtual DOM to find the minimum set of real DOM updates:
#[derive(Debug, PartialEq)]
enum Patch {
Replace(VNode),
UpdateText(String),
AddAttribute(String, String),
RemoveAttribute(String),
AppendChild(VNode),
RemoveChild(usize),
NoChange,
}
fn diff(old: &VNode, new: &VNode) -> Patch {
match (old, new) {
(VNode::Text(old_text), VNode::Text(new_text)) => {
if old_text == new_text {
Patch::NoChange
} else {
Patch::UpdateText(new_text.clone())
}
}
(VNode::Element { tag: old_tag, .. }, VNode::Element { tag: new_tag, .. })
if old_tag != new_tag => {
Patch::Replace(new.clone())
}
_ => Patch::NoChange,
}
}
This is a simplified diff. Real frameworks have much more complex diffing algorithms that handle attribute changes, child reordering, and keyed lists.
Reactive Signals
Modern WASM frameworks use reactive signals — values that automatically trigger UI updates when they change:
struct Signal<T: Clone> {
value: T,
subscribers: Vec<Box<dyn Fn(&T)>>,
}
impl<T: Clone> Signal<T> {
fn new(value: T) -> Self {
Self { value, subscribers: Vec::new() }
}
fn get(&self) -> T {
self.value.clone()
}
fn set(&mut self, new_value: T) {
self.value = new_value;
for subscriber in &self.subscribers {
subscriber(&self.value);
}
}
fn subscribe(&mut self, callback: impl Fn(&T) + 'static) {
self.subscribers.push(Box::new(callback));
}
}
In Leptos, this is the core primitive:
// Leptos example (conceptual)
#[component]
fn Counter() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<button on:click=move |_| set_count.update(|n| *n += 1)>
"Count: " {count}
</button>
}
}
When count changes, only the text inside the button re-renders. The rest of the page stays untouched. This is fine-grained reactivity — more efficient than React’s component-level re-rendering.
JSON Bridge
Data between JavaScript and WASM travels as bytes. For complex data, you serialize to JSON:
#[derive(Debug, Clone, PartialEq)]
enum JsonValue {
Null,
Bool(bool),
Number(f64),
Str(String),
Array(Vec<JsonValue>),
Object(Vec<(String, JsonValue)>),
}
impl JsonValue {
fn to_json_string(&self) -> String {
match self {
JsonValue::Null => "null".to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Number(n) => format!("{}", n),
JsonValue::Str(s) => format!("\"{}\"", s),
JsonValue::Array(arr) => {
let items: Vec<String> = arr.iter()
.map(|v| v.to_json_string()).collect();
format!("[{}]", items.join(","))
}
JsonValue::Object(entries) => {
let items: Vec<String> = entries.iter()
.map(|(k, v)| format!("\"{}\":{}", k, v.to_json_string()))
.collect();
format!("{{{}}}", items.join(","))
}
}
}
}
In practice, you’d use serde with serde-wasm-bindgen for automatic conversion between Rust structs and JavaScript objects.
Canvas Drawing
WASM is great for canvas-based applications: games, visualizations, and image editors:
#[derive(Debug, Clone, PartialEq)]
enum DrawCommand {
MoveTo(f64, f64),
LineTo(f64, f64),
Rect(f64, f64, f64, f64),
Circle(f64, f64, f64),
FillColor(String),
Fill,
Stroke,
Text(String, f64, f64),
Clear,
}
struct Canvas {
commands: Vec<DrawCommand>,
width: u32,
height: u32,
}
In real WASM, each command calls the JavaScript Canvas API through wasm-bindgen:
// Real wasm-bindgen canvas code
#[wasm_bindgen]
pub fn draw(canvas_id: &str) {
let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id(canvas_id).unwrap();
let canvas: web_sys::HtmlCanvasElement = canvas.dyn_into().unwrap();
let ctx = canvas.get_context("2d").unwrap().unwrap();
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
ctx.set_fill_style_str("red");
ctx.fill_rect(10.0, 10.0, 100.0, 50.0);
}
Image Processing
Pixel manipulation is a perfect WASM use case. Processing thousands of pixels in JavaScript is slow. In WASM, it’s instant:
struct ImageBuffer {
pixels: Vec<Pixel>,
width: u32,
height: u32,
}
impl ImageBuffer {
fn grayscale(&self) -> ImageBuffer {
let pixels: Vec<Pixel> = self.pixels.iter().map(|p| {
let gray = (0.299 * p.r as f64
+ 0.587 * p.g as f64
+ 0.114 * p.b as f64) as u8;
Pixel { r: gray, g: gray, b: gray, a: p.a }
}).collect();
ImageBuffer { pixels, width: self.width, height: self.height }
}
fn invert(&self) -> ImageBuffer {
let pixels: Vec<Pixel> = self.pixels.iter().map(|p| {
Pixel { r: 255 - p.r, g: 255 - p.g, b: 255 - p.b, a: p.a }
}).collect();
ImageBuffer { pixels, width: self.width, height: self.height }
}
fn brightness(&self, factor: f64) -> ImageBuffer {
let pixels: Vec<Pixel> = self.pixels.iter().map(|p| {
Pixel {
r: (p.r as f64 * factor).min(255.0) as u8,
g: (p.g as f64 * factor).min(255.0) as u8,
b: (p.b as f64 * factor).min(255.0) as u8,
a: p.a,
}
}).collect();
ImageBuffer { pixels, width: self.width, height: self.height }
}
}
In a real WASM application, you’d get the pixel data from a Canvas ImageData object, process it in Rust, and write it back. The speed difference is dramatic — complex filters that take seconds in JavaScript complete in milliseconds in WASM.
The Rust WASM Ecosystem
wasm-pack
The build tool for Rust WASM projects:
# Install
cargo install wasm-pack
# Build for npm
wasm-pack build --target web
# Build for bundler (webpack/vite)
wasm-pack build --target bundler
It compiles your Rust code to .wasm, generates JavaScript bindings, and creates an npm package.
wasm-bindgen
The glue between Rust and JavaScript:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// Call JavaScript from Rust
#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
web-sys
Rust bindings for all Web APIs (DOM, Canvas, Fetch, WebGL, etc.):
use web_sys::window;
let window = window().unwrap();
let document = window.document().unwrap();
let element = document.get_element_by_id("app").unwrap();
Leptos
A full-stack Rust web framework. Write both frontend and backend in Rust:
use leptos::*;
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<h1>"Counter"</h1>
<button on:click=move |_| set_count.update(|n| *n += 1)>
"Clicked: " {count} " times"
</button>
}
}
Leptos supports:
- Server-side rendering (SSR)
- Client-side hydration
- Fine-grained reactivity
- File-based routing
- Full-stack apps with Actix or Axum
Trunk
A build tool for Rust WASM web apps:
cargo install trunk
trunk serve # dev server with hot reload
trunk build # production build
Getting Started with Real WASM
Create a new WASM project:
cargo new --lib my-wasm-app
cd my-wasm-app
Add to Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
Write your code in src/lib.rs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Build and use:
wasm-pack build --target web
Then in HTML:
<script type="module">
import init, { add } from './pkg/my_wasm_app.js';
await init();
console.log(add(2, 3)); // 5
</script>
When to Use WASM
Good use cases:
- Image/video processing
- Games and simulations
- Cryptography
- Data visualization with Canvas/WebGL
- PDF generation
- Complex calculations (scientific, financial)
- Full web apps with Leptos/Yew
Not ideal for:
- Simple DOM manipulation (JavaScript is fine)
- Small utility functions (WASM module loading has overhead)
- SEO-heavy content sites (server-rendered HTML is better)
Source Code
You can find the complete source code for this tutorial on GitHub:
kemalcodes/rust-tutorial (branch: tutorial-29-wasm)
What’s Next?
In the next tutorial, we learn Unsafe Rust — raw pointers, FFI, unsafe traits, and when unsafe is necessary.