Overview

I avoided WebAssembly for years because the examples always involved rendering 3D graphics or running Photoshop in a browser, which aren't problems I have. Then I had a specific problem — a client-side data transformation that was too slow in JavaScript — and WebAssembly was exactly the right tool. Fifty lines of Rust, 40KB of wasm, and it ran 30x faster than the JS version.

This is what a realistic wasm use case looks like, without the graphics demos.

When WebAssembly is the right answer

Use case Wasm benefit
CPU-heavy computation in the browser Near-native speed
Running existing C/C++/Rust code Reuse without rewriting
Shared code between server and client One implementation, two targets
Serverless with fast cold start Millisecond startup
Plugin systems Sandboxed execution

Wasm is not the right answer for DOM manipulation (it can't access the DOM directly), small data transformations (the JS bridge overhead dominates), or anything where JavaScript is already fast enough.

Toolchain setup

# Install Rust if you haven't
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Add the wasm target
rustup target add wasm32-unknown-unknown

# wasm-pack — the build tool
curl https://rustwasm.GitHub.io/wasm-pack/installer/init.sh -sSf | sh

For bundler integration, the wasm-pack output drops into Vite, webpack, or Rollup with no additional configuration.

A real example: fuzzy matching

The use case I had: a search box filtering 50,000 items with fuzzy matching. JavaScript implementations existed but the UI lagged at ~300ms per keystroke. A Rust implementation of the same algorithm brought it under 10ms.

# Cargo.toml
[package]
name = "fuzzy-match"
version = "0.1.0"
edition = "2021"

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

[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

The profile settings matter for wasm. opt-level = "z" optimizes for size, lto = true enables link-time optimization across crates, codegen-units = 1 trades compile time for smaller output, and panic = "abort" removes unwinding code that wasm can't use anyway.

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

#[wasm_bindgen]
pub struct Matcher {
    items: Vec<String>,
}

#[wasm_bindgen]
impl Matcher {
    #[wasm_bindgen(constructor)]
    pub fn new(items: Vec<String>) -> Matcher {
        Matcher { items }
    }

    pub fn search(&self, query: &str, limit: usize) -> Vec<String> {
        if query.is_empty() {
            return self.items.iter().take(limit).cloned().collect();
        }

        let query_lower = query.to_lowercase();
        let mut scored: Vec<(u32, &String)> = self.items
            .iter()
            .filter_map(|item| {
                score(item, &query_lower).map(|s| (s, item))
            })
            .collect();

        scored.sort_by(|a, b| b.0.cmp(&a.0));
        scored.into_iter()
            .take(limit)
            .map(|(_, s)| s.clone())
            .collect()
    }
}

fn score(haystack: &str, needle: &str) -> Option<u32> {
    let hay_lower = haystack.to_lowercase();
    let mut score = 0u32;
    let mut last_match = 0usize;

    for ch in needle.chars() {
        match hay_lower[last_match..].find(ch) {
            Some(pos) => {
                let absolute = last_match + pos;
                // Bonus for matches at word boundaries
                if absolute == 0 || hay_lower.as_bytes()[absolute - 1] == b' ' {
                    score += 10;
                }
                // Bonus for adjacent matches
                if absolute == last_match {
                    score += 5;
                }
                score += 1;
                last_match = absolute + 1;
            }
            None => return None,
        }
    }

    // Prefer shorter strings when scores are equal
    score = score * 100 / haystack.len() as u32;
    Some(score)
}

The #[wasm_bindgen] macro generates the glue code that exposes these functions to JavaScript. Types like Vec<String> get converted automatically — the boundary crossings aren't free, but for a call that returns 20 items it's negligible.

wasm-pack build --target web --release

Output lands in pkg/ with a JavaScript wrapper and the .wasm binary. For this example, the wasm is about 25KB gzipped.

Using it from JavaScript

import init, { Matcher } from "./pkg/fuzzy_match.js";

await init();

const matcher = new Matcher(allItems);

input.addEventListener("input", (e) => {
  const results = matcher.search(e.target.value, 20);
  renderResults(results);
});

The init() call is required — WebAssembly modules are loaded asynchronously. After that, calls into Rust look like calls into any other JavaScript object.

The boundary crossing cost

Every call across the wasm/JS boundary has overhead. Numbers and booleans are nearly free. Strings and arrays require copying and conversion. For a function that returns a large array of strings, the serialization cost can exceed the computation cost.

Type Cost
u32, f64, bool Essentially free
String Copy + UTF-8/16 conversion
Vec<String> One copy per string
Large structs Expensive — consider passing pointers

The rule: keep wasm calls coarse-grained. Instead of calling a wasm function per item in a loop, pass the whole batch at once and let wasm return the result set. The fuzzy matcher example does this — 50,000 items live in wasm memory, and each search returns only the top 20. This is what makes the difference between 10ms and 300ms.

Sharing memory

For very large data that would be expensive to copy, wasm-bindgen can expose typed arrays that share memory with JavaScript:

#[wasm_bindgen]
pub fn get_buffer() -> *const u8 {
    // return pointer to wasm memory
}

#[wasm_bindgen]
pub fn process_in_place(ptr: *mut u8, len: usize) {
    // modify data in place
}
const ptr = wasm.get_buffer();
const view = new Uint8Array(wasm.memory.buffer, ptr, len);
// view shares memory with wasm

This is powerful but error-prone — if wasm reallocates the memory, the JavaScript view becomes invalid. Use it only for performance-critical paths where you've measured that the copy is the bottleneck.

Debugging

// Log to the browser console
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

log("debug message");

For panics, the default behavior is to abort with a generic message. To get stack traces and readable errors:

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

With this, a Rust panic in the browser produces a stack trace pointing at the source line, mapped back through the wasm debug info. Without it, you get "unreachable executed" and no context.

wasm on the server

The same .wasm file runs on the server in Node.js, Deno, or a wasm runtime like Wasmtime:

// Node.js
const { Matcher } = require("./pkg/fuzzy_match.js");

const matcher = new Matcher(items);
const results = matcher.search("query", 20);

The value here is different from the browser. On the server, wasm gives you:

  • Fast cold starts. A wasm module initializes in microseconds. Same code in a container takes hundreds of milliseconds.
  • Sandboxing. A wasm module can't access the filesystem or network unless you explicitly allow it.
  • Language-agnostic plugins. Your Go or Python service can load plugins written in any language that compiles to wasm.

The plugin case is the interesting one for larger systems. Instead of embedding Lua or writing a custom DSL for extensibility, you define a wasm interface and let users write plugins in Rust, C, Zig, or anything else that targets wasm. Wasmtime and the Extism framework both handle this well.

When not to use wasm

  • Small data transformations. The bridge overhead will exceed any speedup from the computation.
  • Anything touching the DOM heavily. The DOM is JavaScript-native, and any wasm code that needs to manipulate it has to do so through the bridge, which is slower than doing it in JS directly.
  • Code that's already fast in JS. V8's JIT is very good. If your JavaScript isn't slow, wasm won't help.
  • When the toolchain cost isn't justified. Adding a Rust build step to a project that's otherwise pure JavaScript is a real cost in CI time and developer onboarding.

For the fuzzy matcher, wasm was worth it. Thirty times faster, twenty-five kilobytes gzipped, and the project already had Rust tooling. For a project that doesn't, the same speedup might not justify the complexity.

What I'd tell someone starting

  1. Find a specific slow function that's CPU-bound. Profile it in JavaScript first to confirm.
  2. Set up the toolchain and get a "hello world" working. It takes ten minutes.
  3. Port the function to Rust. Keep the interface simple — one call in, one result out.
  4. Benchmark both. If wasm isn't at least 5x faster, the bridge overhead is eating the gains.
  5. Measure the wasm size. If it's over 100KB gzipped for a small utility, check your build profile and dependencies.

The wasm-bindgen book at rustwasm.github.io is the reference for everything above and more.