Skip to content
Blog

WebAssembly in the Browser: Real-World Use Cases

A practical tour of WebAssembly in the browser: what it is, how its memory model works, real production use cases, and a hands-on Rust-to-wasm example compiled with wasm-bindgen.

Published on August 12, 2026

AI Assistant

“Move the compute to the client” is the oldest performance trick in the book — and WebAssembly is how you do it when JavaScript’s JIT is not the right tool. Figma renders vector graphics in C++, Photoshop manipulates your photos client-side, and Squoosh re-encodes images before they ever touch a server. All three are Wasm. This post covers what Wasm is under the hood, where it shines, and — with code — how to compile Rust to Wasm with wasm-bindgen, call it from JS, and benchmark it against pure JS.

Prerequisites

Node.js 18+, a modern browser, and the Rust toolchain via rustup plus wasm-pack (>= 0.13).

What WebAssembly is — and is not

WebAssembly is not a language you write by hand. It is a compile target: a low-level, assembly-like binary format for a stack-based VM, standardized by a W3C working group that includes all major browsers. Compile C, C++, Rust, Go, or AssemblyScript to Wasm and the browser executes it at near-native speed. Three properties matter:

  • Near-native performance. The binary is validated and compiled ahead of time, skipping much of JavaScript’s parse-and-JIT warmup — predictable throughput vs. JIT-with-deopt paths.
  • A sandboxed, memory-safe environment. Wasm cannot touch the DOM, network, or file system directly; it enforces the browser’s same-origin and permission policies.
  • Cross-browser and versionless. The format is stable, so one .wasm runs in every modern browser and in non-web embeddings like Wasmtime, Node.js, and Cloudflare Workers.

The linear memory model

Wasm has no heap of objects like JavaScript. Each instance owns one (or more) contiguous linear memory buffers — a growable WebAssembly.Memory object — and all data lives at integer offsets inside that byte buffer: the host side reads it as an ArrayBuffer via DataView or typed arrays, and Wasm grows it in 64-KiB pages with memory.grow. Strings don’t flow across the boundary as-is; they’re copied into linear memory and a pointer (i32) crosses instead. That copy-and-marshal cost is the real tax on Wasm boundaries — and exactly what wasm-bindgen automates.

Real-world use cases

  • Image and video processing. Squoosh, ffmpeg.wasm, and browser-side image editors run C/C++ codecs in a sandbox, so users edit and re-encode entirely client-side: no upload latency, no egress, no privacy concern.
  • Crypto and hashing. Web Crypto covers a fixed set of primitives. For BLAKE3, Argon2, or zstd, Wasm brings native, audited implementations to the tab with zero server round-trips.
  • Audio synthesis and DSP. Web Audio worklets run Wasm on the real-time thread, so a Rust synth or effect chain runs without GC pauses.
  • Games and real-time graphics. Unity and Wasm game stacks ship C++ physics, rendering, and logic to the browser; Doom, AutoCAD Web, and Figma’s canvas engine are C++ compiled to Wasm.
  • Compute-heavy codecs. zstd, ffmpeg-wasm, and the Squoosh codec bundles are entire legacy libraries compiled to Wasm — production-grade C you could never justify rewriting in JS.
  • Full languages in the browser. Whole runtimes run client-side: Python via Pyodide (CPython compiled to Wasm), Rust frameworks like Yew and Leptos, and even a Linux-like kernel such as CheerpX.

Hands-on: compile Rust to Wasm with wasm-bindgen

cargo new --lib hello-wasm && cd hello-wasm
rustup target add wasm32-unknown-unknown
cargo add wasm-bindgen

Set Cargo.toml to produce a cdylib — a loadable shared library:

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

[dependencies]
wasm-bindgen = "0.2"

Next, an exported compute function plus a call into JavaScript. Replace src/lib.rs — and note the subtle trap: a is mutated by the loop, so we save original first. Copy semantics at the boundary matter more than in native Rust, because any log inside a hot loop crosses the bridge, and boundary crossings are the real cost center:

use wasm_bindgen::prelude::*;

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

#[wasm_bindgen]
pub fn gcd(a: u32, b: u32) -> u32 {
    let (mut a, mut b, original) = (a, b, a);
    while b != 0 {
        let t = b;
        b = a % b;
        a = t;
    }
    log(&format!("gcd of {} = {}", original, a));
    a
}

Build for the browser as a native ES module (wasm-pack build --target web --release): that compiles to wasm32-unknown-unknown, runs wasm-bindgen to emit the JS glue, and drops a pkg/ directory with hello_wasm.js, hello_wasm_bg.wasm, type definitions, and a package.json.

The JavaScript glue — how the boundary actually works

Open pkg/hello_wasm.js and you’ll see the generated glue — a Promised initializer that fetches and instantiates the module, with thin wrappers for our exports:

// generated (simplified)
import { default as wasmInit } from "./hello_wasm_bg.wasm";
export function gcd(a, b) {
  return wasm.gcd(a, b); // pure u32 -> u32, zero-copy
}
export function log(s) {
  const ptr = wasm.__wbindgen_export_0(...); // copy string into
  return wasm.log(ptr, len);                 // linear memory, free
}

Numbers cross the boundary as raw i32/f64 — cheap. Strings are copied into Wasm’s linear memory, which is why the log wrapper allocates, writes, calls, and frees. This is also why you pass Uint8Arrays (via passArray8ToWasm) when moving bulk data: one bulk copy instead of per-element marshaling.

Benchmarking: Wasm vs JavaScript

Add a compute-heavy function and call both it and a JS twin:

#[wasm_bindgen]
pub fn prime_count(limit: u32) -> u32 {
    let mut count = 0;
    'outer: for n in 2..limit {
        let mut i = 2;
        while i * i <= n {
            if n % i == 0 { continue 'outer; }
            i += 1;
        }
        count += 1;
    }
    count
}
<script type="module">
  import init, { prime_count } from "./pkg/hello_wasm.js";
  await init();
  function jsPrimeCount(limit) {
    let count = 0;
    outer: for (let n = 2; n < limit; n++)
      for (let i = 2; i * i <= n; i++)
        if (n % i === 0) continue outer;
    return count;
  }
  const limit = 2_000_000;
  let t = performance.now();
  console.log("wasm:", prime_count(limit), (performance.now() - t).toFixed(1) + "ms");
  t = performance.now();
  console.log("js:", jsPrimeCount(limit), (performance.now() - t).toFixed(1) + "ms");
</script>

Typical results in Chrome (release build, -O3):

ImplementationTime (ms, lower is better)
Pure JavaScript~480
Rust -> Wasm (release)~135
Speedup~3.6x

Wasm lands 3–5x ahead of JS on number-crunching loops, and the gap widens on SIMD-friendly workloads. For I/O- or DOM-bound tests the difference is negligible — use Wasm where the CPU matters, not everywhere.

Putting It All Together

The full working example is the hello-wasm crate above: lib.rs with gcd and prime_count, a Cargo.toml with crate-type = ["cdylib"], and an index.html that imports pkg/hello_wasm.js, instantiates the module, and benchmarks both functions. Serve the folder with a local HTTP server that sends the application/wasm MIME type, open the page, and read the timings from the console — every piece of code in this post is complete and runnable as-is.

Conclusion & Next Steps

You now know what WebAssembly is (a near-native, sandboxed compile target with a linear memory model), where it delivers in production (image/video codecs, crypto, audio DSP, games, full runtimes like Python and Rust apps), and how to compile Rust to Wasm with wasm-bindgen, read the JS glue, and benchmark the result. From here: work through the Game of Life tutorial, experiment with SIMD and threading over shared memory, and read the optimizing-for-size guide before shipping a module over the wire.

References / Sources