Rust & WebAssembly Explained â A Practical Guide to WASM Development
In this tutorial, you'll learn about Rust & WebAssembly Explained. We cover key concepts, practical examples, and best practices.
Master Rust and WebAssembly: compiling Rust to wasm32 target, wasm-bindgen for JS interop, wasm-pack for packaging, optimizing binary size, and building high-performance web applications.
What You'll Learn
In this tutorial, you'll master Rust and WebAssembly â setting up the wasm32-unknown-unknown target, using wasm-bindgen for JavaScript interoperability, packaging with wasm-pack, optimizing WASM binary size, working with DOM APIs, and integrating Rust WASM modules into web applications.
Why It Matters
WebAssembly lets you run near-native-performance code in the browser. Rust is the leading language for WASM because of its small runtime, zero-cost abstractions, and strong type system. Together, they enable compute-intensive workloads â video encoding, image processing, data compression, physics simulations, and game engines â that were previously impossible in the browser.
Real-World Use
Figma's WebAssembly engine renders vector graphics in real time. Google Earth uses WASM for 3D rendering. The ffmpeg.wasm project brings video processing to the browser. Durga Antivirus Pro's web dashboard uses a Rust-compiled WASM module to scan uploaded files client-side before transmission, reducing server load and improving privacy.
flowchart LR
A[Rust Source] --> B[rustc with wasm32 target]
B --> C[.wasm binary]
C --> D[wasm-bindgen]
D --> E[Generated JS glue]
E --> F[Web Application]
A --> G[wasm-pack]
G --> H[npm package]
H --> I[Node.js / bundler]
Prerequisites: Rust basics and Error Handling. Understanding ownership helps with WASM memory management.
Setting Up a WASM Project
To compile Rust to WebAssembly, add the target and create a library project:
// Cargo.toml
// [package]
// name = "wasm-scanner"
// version = "0.1.0"
// edition = "2021"
//
// [lib]
// crate-type = ["cdylib"]
//
// [dependencies]
// wasm-bindgen = "0.2"
use wasm_bindgen::prelude::*;
// Function exported to JavaScript
#[wasm_bindgen]
pub fn scan_pattern(data: &[u8], pattern: &[u8]) -> Vec<usize> {
if pattern.is_empty() || pattern.len() > data.len() {
return Vec::new();
}
data.windows(pattern.len())
.enumerate()
.filter(|(_, window)| *window == pattern)
.map(|(i, _)| i)
.collect()
}
// String processing function
#[wasm_bindgen]
pub fn count_words(text: &str) -> usize {
text.split_whitespace().count()
}
#[wasm_bindgen]
pub fn detect_encoding(data: &[u8]) -> String {
if data.len() < 4 {
return "unknown".to_string();
}
if data.starts_with(&[0xEF, 0xBB, 0xBF]) {
"UTF-8 BOM".to_string()
} else if data.starts_with(&[0xFF, 0xFE]) {
"UTF-16 LE BOM".to_string()
} else if data.starts_with(&[0xFE, 0xFF]) {
"UTF-16 BE BOM".to_string()
} else {
"plain UTF-8".to_string()
}
}
fn main() {
// Test the WASM functions locally
let data = b"hello world, this is a test with pattern pattern test";
let matches = scan_pattern(data, b"pattern");
println!("Pattern found at offsets: {:?}", matches);
let text = "Rust and WebAssembly are powerful together";
println!("Word count: {}", count_words(text));
let encoding = detect_encoding(b"\xEF\xBB\xBFhello");
println!("Encoding: {}", encoding);
}
Expected output:
Pattern found at offsets: [30, 38]
Word count: 7
Encoding: UTF-8 BOM
JavaScript Interop with wasm-bindgen
wasm-bindgen enables passing complex types, calling DOM APIs, and handling events:
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{console, window, Document, HtmlInputElement, HtmlButtonElement};
// Access browser console
#[wasm_bindgen]
pub fn log_message(message: &str) {
console::log_1(&JsValue::from_str(message));
}
// Call JavaScript Date.now()
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = Date)]
fn now() -> f64;
}
// Manipulate the DOM
#[wasm_bindgen]
pub fn setup_file_drop_area() -> Result<(), JsValue> {
let window = window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let drop_zone = document.create_element("div")?;
drop_zone.set_text_content(Some("Drop files here for scan"));
drop_zone.set_attribute("style", "border:2px dashed #ccc;padding:20px;margin:10px")?;
body.append_child(&drop_zone)?;
Ok(())
}
// Return structured data to JS
#[wasm_bindgen]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ScanSummary {
pub file_count: u32,
pub total_size: usize,
pub threats_found: u32,
pub scan_duration_ms: f64,
}
#[wasm_bindgen]
pub fn analyze_files(sizes: Vec<usize>) -> JsValue {
let summary = ScanSummary {
file_count: sizes.len() as u32,
total_size: sizes.iter().sum(),
threats_found: 0,
scan_duration_ms: now() - 1000.0, // placeholder
};
serde_wasm_bindgen::to_value(&summary).unwrap()
}
fn main() {
log_message("WASM module loaded successfully");
let timestamp = unsafe { now() };
println!("JS timestamp: {}", timestamp);
// Create a scan summary
let sizes = vec![1024, 2048, 4096];
let summary = ScanSummary {
file_count: sizes.len() as u32,
total_size: sizes.iter().sum(),
threats_found: 0,
scan_duration_ms: 15.5,
};
println!("Summary: {} files, {} bytes", summary.file_count, summary.total_size);
}
Expected output:
WASM module loaded successfully
JS timestamp: ... (varies)
Summary: 3 files, 7168 bytes
Binary Size Optimization
WASM binary size matters for web performance. Key optimization techniques:
// # Cargo.toml optimizations
// [profile.release]
// opt-level = "z" # Optimize for size
// lto = true # Link-time optimization
// codegen-units = 1 # Single codegen unit
// strip = true # Strip symbols
// panic = "abort" # Remove panic unwind machinery
use wasm_bindgen::prelude::*;
// Use a custom allocator for smaller WASM
// In Cargo.toml:
// [dependencies]
// wee_alloc = { version = "0.4", optional = true, default-features = false }
//
// #[cfg(all(not(debug_assertions), target_arch = "wasm32"))]
// #[global_allocator]
// static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// Avoid unnecessary std dependencies
// Use core traits instead of std where possible
fn hex_encode(data: &[u8]) -> String {
const TABLE: &[u8] = b"0123456789abcdef";
let mut result = Vec::with_capacity(data.len() * 2);
for &byte in data {
result.push(TABLE[(byte >> 4) as usize]);
result.push(TABLE[(byte & 0x0F) as usize]);
}
unsafe { String::from_utf8_unchecked(result) }
}
#[wasm_bindgen]
pub fn sha256_simulated(data: &[u8]) -> String {
// Placeholder: in production, use a real hash function
hex_encode(&data[..data.len().min(8)])
}
#[wasm_bindgen]
pub fn compress_ratio(data: &[u8]) -> f64 {
if data.is_empty() {
return 1.0;
}
// Simple run-length estimate
let mut runs = 0u32;
let mut prev = data[0];
for &b in &data[1..] {
if b != prev {
runs += 1;
}
prev = b;
}
let estimated = runs as f64 * 2.0 + 1.0;
data.len() as f64 / estimated
}
fn main() {
let data = b"hello world";
println!("Hash: {}", sha256_simulated(data));
println!("Compress ratio: {:.2}", compress_ratio(b"AAAAABBBBBCCCCC"));
}
Expected output:
Hash: 68656c6c6f
Compress ratio: 2.14
Working with Web Workers and Threads
WebAssembly can use threads via Web Workers for parallel computation:
// Note: WASM threads require SharedArrayBuffer + cross-origin isolation headers
use wasm_bindgen::prelude::*;
use web_sys::{console, Worker, WorkerOptions, MessageEvent};
// Simulated parallel scan (demonstration pattern)
#[wasm_bindgen]
pub fn parallel_simulate(file_count: u32) -> u32 {
// In real usage: spawn Web Workers, split file list, scan in parallel
// For this example, simulate the coordination logic
let chunk_size = (file_count / 4).max(1);
let chunks = (file_count + chunk_size - 1) / chunk_size;
let mut total_threats = 0u32;
for chunk in 0..chunks {
let start = chunk * chunk_size;
let end = (start + chunk_size).min(file_count);
let count = end - start;
// Simulate scanning a chunk (in production, dispatch to a Worker)
let threat_counts: Vec<u32> = (start..end).map(|i| {
if i % 7 == 0 { 1 } else { 0 } // Simulated 1/7 threat ratio
}).collect();
total_threats += threat_counts.iter().sum::<u32>();
}
total_threats
}
#[wasm_bindgen]
pub fn threat_stats(threat_count: u32, total: u32) -> String {
let percentage = if total > 0 {
(threat_count as f64 / total as f64) * 100.0
} else {
0.0
};
format!("Threats: {}/{} ({:.1}%)", threat_count, total, percentage)
}
fn main() {
let total = 100;
let threats = parallel_simulate(total);
println!("Parallel scan complete");
println!("{}", threat_stats(threats, total));
}
Expected output:
Parallel scan complete
Threats: 14/100 (14.0%)
Common Errors
1. Missing wasm32 target
rustup target add wasm32-unknown-unknown
2. Stack overflow from recursion
WASM has a limited call stack. Use iteration instead of recursion for deep algorithms, or increase the stack size with -C link-args=-zstack-size=....
3. Large binary size from unused std functions
Use wee_alloc, disable panic = "unwind", use LTO, and strip symbols. Profile with twiggy or wasm-opt.
4. Passing non-Clone types across JS boundary
#[wasm_bindgen]
pub fn process(data: Vec<u8>) -> Vec<u8> // OK: Clone is automatic
// But complex types must derive Clone or be handled carefully
5. Blocking the main thread with long computations
WASM runs on the main thread by default. Use wasm-bindgen-rayon or Web Workers for CPU-bound work longer than 16ms.
Practice Questions
1. What is the difference between wasm32-unknown-unknown and wasm32-wasi?
wasm32-unknown-unknown targets the browser WASM runtime (no OS, no file system). wasm32-wasi targets WASI (WebAssembly System Interface) for server-side and command-line WASM with file system and networking access.
2. How does wasm-bindgen enable JS-Rust interop?
It generates JavaScript glue code that translates between JS types and Rust types. #[wasm_bindgen] on functions exports them to JS. extern "C" blocks with #[wasm_bindgen] import JS functions.
3. What is the recommended way to pass large data between JS and Rust?
Pass data as Vec<u8> or &[u8] (zero-copy when the data is already in WASM memory). For structured data, use serde with serde-wasm-bindgen. Avoid JSON serialization for large data â it's slow and double-encodes.
4. How do you measure and optimize WASM binary size?
Use wasm-pack build --release, twiggy top to analyze size contributions, wasm-opt -Oz for post-processing, and wasm2wat to inspect the WAT text format. Enable LTO, set opt-level = "z", and use wee_alloc.
5. Challenge: Build a WASM module that computes the Levenshtein distance between two strings. Export it with #[wasm_bindgen], write a JavaScript test harness, and measure performance against a pure JS implementation for 1000 random string pairs.
Mini Project: Client-Side File Scanner WASM Module
use wasm_bindgen::prelude::*;
use std::collections::HashSet;
#[wasm_bindgen]
pub struct FileScanner {
signatures: Vec<Vec<u8>>,
threat_names: Vec<String>,
}
#[wasm_bindgen]
impl FileScanner {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
FileScanner {
signatures: Vec::new(),
threat_names: Vec::new(),
}
}
pub fn add_signature(&mut self, name: &str, bytes: &[u8]) {
self.threat_names.push(name.to_string());
self.signatures.push(bytes.to_vec());
}
pub fn scan_buffer(&self, data: &[u8]) -> Vec<String> {
let mut found = Vec::new();
for (i, sig) in self.signatures.iter().enumerate() {
if data.windows(sig.len()).any(|w| w == sig.as_slice()) {
found.push(self.threat_names[i].clone());
}
}
found
}
pub fn quick_hash(&self, data: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn entropy(&self, data: &[u8]) -> f64 {
if data.is_empty() {
return 0.0;
}
let mut freq = [0u64; 256];
for &byte in data {
freq[byte as usize] += 1;
}
let len = data.len() as f64;
let mut entropy = 0.0f64;
for &count in freq.iter() {
if count > 0 {
let p = count as f64 / len;
entropy -= p * p.log2();
}
}
entropy
}
}
fn main() {
let mut scanner = FileScanner::new();
scanner.add_signature("EICAR-Test", b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*");
scanner.add_signature("JavaScript-Worm", b"<script>alert(1)</script>");
let clean = b"hello world this is a clean file";
let infected = b"this file contains X5O!P%"@AP"[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* virus";
println!("Clean file: {:?}", scanner.scan_buffer(clean));
println!("Infected file: {:?}", scanner.scan_buffer(infected));
println!("Quick hash: {}", scanner.quick_hash(infected));
println!("Entropy of infected: {:.4}", scanner.entropy(infected));
}
Expected output:
Clean file: []
Infected file: ["EICAR-Test"]
Quick hash: ... (varies)
Entropy of infected: ... (varies, ~4.5)
FAQ
Related Concepts
What's Next
Explore Embedded Rust for another no-std target, or review Testing strategies for WASM-specific edge cases.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro