Skip to main content
โšก Calmops

WebAssembly with Rust: Building Browser & Server Apps

Introduction

WebAssembly (WASM) is a bytecode format that runs at near-native speeds in browsers and servers. Rust is one of the best languages for WebAssembly development, offering memory safety and zero-cost abstractions.

This guide covers building WebAssembly applications with Rust for multiple targets.


Why Rust + WebAssembly?

Performance Comparison

Browser Runtime Performance (Matrix multiplication 1000x1000):

Language/Runtime    Time      Memory    Speed vs JS
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
JavaScript          850ms     150MB     1x
Rust โ†’ WASM         45ms      20MB      19x faster
Python (js)         4500ms    500MB     0.19x
Go โ†’ WASM           60ms      50MB      14x faster
C โ†’ WASM            40ms      10MB      21x faster

Key Advantages

โœ… Near-native performance (80-90% of native)
โœ… Memory safety guarantees
โœ… Smaller bundle sizes than JavaScript
โœ… Can share code between browser and server
โœ… Excellent tooling (wasm-pack, wasm-bindgen)
โœ… Growing ecosystem

Setup and Tooling

Installation

# Install Rust
rustup update

# Install WASM target
rustup target add wasm32-unknown-unknown

# Install wasm-pack (build tool)
curl https://rustwasm.org/wasm-pack/installer/init.sh -sSf | sh

# Install necessary tools
cargo install wasm-pack

Create Project

# Create new WASM library
wasm-pack new my_wasm_app

# Or convert existing Rust project
cargo init my_project
# Add to Cargo.toml:
# [lib]
# crate-type = ["cdylib"]

Hello World in WASM

Rust Code

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[wasm_bindgen]
pub struct Counter {
    count: i32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    pub fn increment(&mut self) {
        self.count += 1;
    }

    pub fn get_count(&self) -> i32 {
        self.count
    }
}

Cargo.toml

[package]
name = "hello_wasm"
version = "0.1.0"
edition = "2021"

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

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

Build

wasm-pack build --target web

# Output:
# pkg/
# โ”œโ”€โ”€ hello_wasm.js
# โ”œโ”€โ”€ hello_wasm.d.ts
# โ”œโ”€โ”€ hello_wasm_bg.wasm (binary)
# โ””โ”€โ”€ package.json

JavaScript Usage

// Import the WASM module
import init, { greet, Counter } from './pkg/hello_wasm.js';

async function main() {
    await init();
    
    // Call Rust function
    console.log(greet("World"));  // "Hello, World!"
    
    // Use Rust struct
    const counter = new Counter();
    counter.increment();
    counter.increment();
    console.log(counter.get_count());  // 2
}

main();

DOM Manipulation from Rust

web-sys Crate

use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlElement, Window};

#[wasm_bindgen]
pub fn manipulate_dom() -> Result<(), JsValue> {
    // Get window and document
    let window = web_sys::window()
        .ok_or("No window")?;
    let document = window.document()
        .ok_or("No document")?;

    // Create element
    let div = document.create_element("div")?;
    div.set_inner_html("Hello from Rust!");

    // Append to body
    let body = document.body()
        .ok_or("No body")?;
    body.append_child(&div)?;

    Ok(())
}

Event Handling

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlButtonElement;

#[wasm_bindgen]
pub fn setup_click_handler() -> Result<(), JsValue> {
    let document = web_sys::window()
        .unwrap()
        .document()
        .unwrap();

    let button = document
        .get_element_by_id("my-button")
        .ok_or("No button")?
        .dyn_into::<HtmlButtonElement>()?;

    let closure = Closure::new(|| {
        web_sys::console::log_1(&"Button clicked!".into());
    });

    button.set_onclick(Some(closure.as_ref().unchecked_ref()));
    closure.forget();

    Ok(())
}

Performance-Critical Code

Image Processing Example

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct ImageProcessor {
    data: Vec<u8>,
    width: u32,
    height: u32,
}

#[wasm_bindgen]
impl ImageProcessor {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> ImageProcessor {
        ImageProcessor {
            data: vec![0; (width * height * 4) as usize],
            width,
            height,
        }
    }

    // Apply grayscale filter
    pub fn grayscale(&mut self) {
        for pixel in self.data.chunks_exact_mut(4) {
            let r = pixel[0] as f32;
            let g = pixel[1] as f32;
            let b = pixel[2] as f32;
            
            let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
            pixel[0] = gray;
            pixel[1] = gray;
            pixel[2] = gray;
        }
    }

    // Get pixel data
    pub fn data_ptr(&self) -> *const u8 {
        self.data.as_ptr()
    }

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }
}

Server-Side WASM

WASM on Node.js

// src/lib.rs - Same Rust code works on server!
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

Node.js Setup

# Cargo.toml
[lib]
crate-type = ["cdylib"]
# Build for Node.js
wasm-pack build --target nodejs
// JavaScript using Node.js WASM
const { fibonacci } = require('./pkg/my_wasm_bg.js');

console.log(fibonacci(10));  // Fast calculation!

// Export as CommonJS module
module.exports = { fibonacci };

Interop: Passing Complex Data

Serialization

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[wasm_bindgen]
pub fn distance(p1: &JsValue, p2: &JsValue) -> f64 {
    let p1: Point = p1.into_serde().unwrap();
    let p2: Point = p2.into_serde().unwrap();
    
    let dx = p2.x - p1.x;
    let dy = p2.y - p1.y;
    (dx * dx + dy * dy).sqrt()
}

JavaScript

import { distance } from './pkg/my_wasm.js';

const p1 = { x: 0, y: 0 };
const p2 = { x: 3, y: 4 };

console.log(distance(p1, p2));  // 5.0

Memory Management

Shared Memory

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Buffer {
    data: Vec<u8>,
}

#[wasm_bindgen]
impl Buffer {
    #[wasm_bindgen(constructor)]
    pub fn new(size: usize) -> Buffer {
        Buffer {
            data: vec![0; size],
        }
    }

    // Get mutable pointer for direct access
    pub fn as_mut_ptr(&mut self) -> *mut u8 {
        self.data.as_mut_ptr()
    }

    // Get immutable pointer
    pub fn as_ptr(&self) -> *const u8 {
        self.data.as_ptr()
    }

    pub fn len(&self) -> usize {
        self.data.len()
    }
}

JavaScript Memory Access

import { Buffer } from './pkg/buffer.js';

const buffer = new Buffer(1024);
const ptr = buffer.as_ptr();

// Create JavaScript view of WASM memory
const memory = buffer.memory.buffer;
const view = new Uint8Array(memory, ptr, 1024);

// Direct write
view[0] = 42;
view[1] = 100;

Real-World Example: Mandelbrot Set

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn compute_mandelbrot(
    width: u32,
    height: u32,
    max_iter: u32,
) -> Vec<u8> {
    let mut result = vec![0u8; (width * height) as usize];

    for y in 0..height {
        for x in 0..width {
            let cx = -2.5 + (x as f64 / width as f64) * 3.5;
            let cy = -1.25 + (y as f64 / height as f64) * 2.5;

            let mut zx = 0.0;
            let mut zy = 0.0;
            let mut iter = 0;

            while iter < max_iter && zx * zx + zy * zy <= 4.0 {
                let tx = zx * zx - zy * zy + cx;
                zy = 2.0 * zx * zy + cy;
                zx = tx;
                iter += 1;
            }

            result[(y * width + x) as usize] = (iter % 256) as u8;
        }
    }

    result
}

JavaScript Renderer

import { compute_mandelbrot } from './pkg/mandelbrot.js';

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

// Compute in Rust (fast!)
const data = compute_mandelbrot(800, 600, 100);

// Render in JavaScript
const imageData = ctx.createImageData(800, 600);
for (let i = 0; i < data.length; i++) {
    imageData.data[i * 4] = data[i];      // R
    imageData.data[i * 4 + 1] = data[i];  // G
    imageData.data[i * 4 + 2] = data[i];  // B
    imageData.data[i * 4 + 3] = 255;      // A
}

ctx.putImageData(imageData, 0, 0);

Debugging

console API

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn debug_example() {
    web_sys::console::log_1(&"Debug message".into());
    web_sys::console::warn_1(&"Warning!".into());
    web_sys::console::error_1(&"Error!".into());
}

Browser DevTools

# Build with source maps
wasm-pack build --dev

# In Firefox:
# about:debugging โ†’ Components โ†’ WebAssembly
# Can set breakpoints and step through code!

Optimization Tips

Performance optimizations:

โœ… Use --release builds for production
โœ… Enable LTO: lto = true in Cargo.toml
โœ… Minimize allocations in hot paths
โœ… Use SIMD when available
โœ… Profile with Web Performance API
โœ… Keep WASM modules < 1MB
โœ… Use streaming compilation

Bundle Size Optimization

Typical sizes:

Code                Size    Compressed
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Hello world        50KB    15KB
Image processor    200KB   60KB
Game engine        5MB     1.5MB

Optimization techniques:
- Use wee_alloc for smaller allocator
- Remove debug symbols
- Use cargo-bloat to find large functions
- Enable link-time optimization (LTO)

Glossary

  • WASM: WebAssembly - bytecode format
  • wasm-bindgen: Glue code between JS and Rust
  • wasm-pack: Build tool for WASM projects
  • cdylib: Dynamic library crate type
  • JsValue: JavaScript value from Rust

Resources


Comments