Performance

Rust WASM vs JavaScript — When Is WASM Faster?

Rust WASM outperforms JavaScript in compute-heavy tasks but adds overhead for simple DOM operations.

When the browser freezes on a slider

You're building a photo editor in the browser. You drag a slider to adjust contrast. In JavaScript, the UI freezes for a second while a loop runs over ten million pixels. You switch to Rust WASM, and the slider stays buttery smooth. The difference isn't magic. It's about how the browser executes your code.

JavaScript engines are incredible at turning text into action, but they still have to parse and optimize on the fly. WebAssembly is a compiled binary format. Think of JavaScript as a chef reading a recipe written in prose while cooking. They have to parse the words, figure out the steps, and sometimes guess the intent. WebAssembly is like a chef following a precise, numbered checklist where every step is already optimized. The chef doesn't waste time reading. They just execute.

A minimal loop

Here's a simple computation. It sums the squares of numbers up to n. In JavaScript, this runs fine for small n. For large n, the overhead of type checks and garbage collection adds up. In Rust, the types are fixed. The memory layout is known. The compiler generates tight machine code.

/// Computes the sum of squares for a range of numbers.
pub fn sum_squares(n: u32) -> u64 {
    let mut sum = 0; // Accumulator for the result.
    for i in 0..n { // Loop runs n times.
        sum += (i as u64) * (i as u64); // Square and add.
    }
    sum // Return the final total.
}

The compiler knows sum is a u64. It knows i is a u32. It doesn't need to check types at runtime. It emits instructions that add and multiply directly. The result is a sequence of CPU operations with zero overhead.

What happens under the hood

JavaScript engines use Just-In-Time compilation. They compile code as it runs. Hot paths get optimized. Cold paths stay interpreted. This means performance can vary based on how the code is used. The engine might de-optimize if a variable changes type unexpectedly. WebAssembly is Ahead-Of-Time compiled. The binary is ready to run. There's no warmup phase. The performance is consistent from the first instruction.

Memory management differs too. JavaScript uses a garbage collector. The GC pauses execution to reclaim unused memory. These pauses can cause jank in real-time applications. Rust uses deterministic ownership. Memory is freed when values go out of scope. No pauses. No surprises. The CPU keeps running.

When you compile Rust to WASM, the output is a .wasm file. The browser loads this file and executes it in a sandboxed environment. The WASM module has its own linear memory. Rust code reads and writes to this memory directly. JavaScript can access the memory too, but it must go through the browser's API. This separation keeps the web safe. It also means data transfer has a cost.

Processing image data

Real-world WASM usage often involves processing large buffers. Image data, audio samples, or cryptographic payloads. Here's how you expose a Rust function to JavaScript.

use wasm_bindgen::prelude::*;

/// Applies a grayscale filter to raw pixel data.
/// Expects pixels in RGBA format, 4 bytes per pixel.
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
    // Iterate over pixels in chunks of 4 bytes.
    for chunk in pixels.chunks_mut(4) {
        let r = chunk[0];
        let g = chunk[1];
        let b = chunk[2];
        // Calculate luminance using standard weights.
        let gray = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) as u8;
        chunk[0] = gray;
        chunk[1] = gray;
        chunk[2] = gray;
        // Alpha channel remains unchanged.
    }
}

The #[wasm_bindgen] macro generates the glue code. It tells the compiler to export this function and create a JavaScript wrapper. The wrapper handles type conversion and memory transfer. You call grayscale from JavaScript with a Uint8Array. The data crosses the boundary once. Rust processes it in place. The result is available immediately.

Convention: The community standard for bridging Rust and JS is wasm-bindgen. You annotate functions with #[wasm_bindgen] to expose them. The macro generates the bindings. Don't write manual FFI bindings unless you have a specific reason. The generated bindings handle memory transfer and type conversion safely. Trust wasm-bindgen. It handles the bridge. You handle the logic.

The boundary tax

WebAssembly isn't faster at everything. The browser's DOM API is optimized for JavaScript. Calling DOM methods from WASM requires crossing the boundary. You convert Rust types to JavaScript types, call the API, and convert results back. This serialization cost eats any performance gain. If you're manipulating the DOM, stick to JavaScript. The compiler won't save you here. You'll just add overhead.

Cross the boundary once. Batch your work. Don't ping-pong between Rust and JS. If you need to update 1000 elements, calculate the new state in Rust, serialize it to a single buffer, and pass it to JavaScript. Let JavaScript update the DOM in one pass. The boundary is expensive. Minimize crossings.

Convention: Always build with --release. Debug builds include instrumentation and skip optimizations. A debug WASM build can be ten times slower than the release version. The community treats --release as the default for any performance discussion. If you benchmark a debug build, you're measuring the wrong thing.

Convention: wasm-pack is the standard toolchain. It builds the WASM, generates bindings, and runs wasm-opt to shrink the binary. wasm-opt removes dead code and optimizes instructions. The resulting binary is smaller and faster. Use wasm-pack unless you have a custom build pipeline.

Pitfalls and errors

If you try to return a reference to a Rust allocation to JavaScript, the compiler rejects you with E0515 (cannot return reference to temporary value) or similar lifetime errors. JavaScript can't hold a Rust reference. You must transfer ownership or copy data. The borrow checker enforces this rule. It prevents dangling pointers across the boundary.

Another common issue is stack overflow. WASM has a limited stack size. Deep recursion or large stack allocations can crash the module. Use Box or heap allocation for large data. Keep stack usage small. The browser will terminate the tab if the stack overflows.

If you use std::web_sys to call browser APIs, you'll encounter trait bounds. The compiler might reject you with E0277 (trait bound not satisfied) if you miss a feature flag. web-sys is feature-gated. You must enable the specific APIs you use in Cargo.toml. Check the documentation for the correct feature names.

Decision matrix

Use Rust WASM for CPU-bound tasks like image processing, cryptography, or physics simulations where raw computation dominates. Use Rust WASM for complex algorithms where JavaScript's type system and garbage collector introduce unacceptable latency. Use Rust WASM when you need to share logic between the web and a native backend without rewriting the core algorithm. Use JavaScript for DOM manipulation and UI event handling where browser APIs are optimized for native script execution. Use JavaScript for I/O-heavy operations like fetching data or reading files where network latency dwarfs processing time. Use JavaScript for prototyping and rapid iteration where build times matter more than runtime performance.

Measure before you optimize. Premature WASM adoption adds build complexity for zero gain. Profile your JavaScript first. Find the bottleneck. Then bring in Rust where it counts.

Where to go next