Engineering

When WebAssembly Beats JavaScript — And When It Does Not

Side-by-side timings for Fibonacci, primes, and sorting — practical guidance on where WASM earns its keep in the browser.

View reference repoOpen live demo

WebAssembly promises near-native speed inside the browser, but adopting it is not a universal performance upgrade. Every call across the JavaScript boundary carries overhead. Modern JavaScript engines compile hot paths aggressively. And the workloads that dominate most web applications — DOM updates, network I/O, layout — rarely benefit from moving compute into a WASM module.

To make the trade-offs concrete, we built a Next.js demo that runs identical algorithms in JavaScript and in Rust-compiled WebAssembly, then charts the results side by side. The source, build pipeline, and interactive benchmarks live in learn_wasm-performance-tests. The deployed demo is at learn-wasm-performance-tests.

What the Demo Compares

Three CPU-bound microbenchmarks mirror common “is WASM faster?” questions:

  • Fibonacci — naive recursive implementation with exponential call depth, stressing function-call overhead and stack behavior.
  • Prime checking — trial division with wheel optimization, a tight integer loop with predictable branches.
  • Array sorting — in-place sort of a randomly generated Int32Array, measuring bulk memory access and comparison throughput.

JavaScript implementations live in src/lib/js-implementations.ts. Rust equivalents are compiled with wasm-pack from wasm/src/lib.rs and exposed through wasm_bindgen. A reusable PerformanceTest component accepts both function references, generates input at a configurable size, times each run with performance.now(), and renders a bar chart of the results.

const runTest = () => {
  const input = inputGenerator(inputSize);

  const jsStart = performance.now();
  jsFunction(input);
  setJsTime(performance.now() - jsStart);

  const wasmStart = performance.now();
  wasmFunction(input);
  setWasmTime(performance.now() - wasmStart);
};

The JavaScript Fibonacci baseline is intentionally naive — the point is to compare raw compute paths, not to showcase memoization or iterative alternatives:

export function fibonacciJS(n: number): number {
  if (n <= 1) return n;
  return fibonacciJS(n - 1) + fibonacciJS(n - 2);
}

Methodology Caveats

Before drawing conclusions from any single bar chart, understand what these numbers measure — and what they omit.

Read the numbers carefully

The demo times one synchronous call per click. It does not warm up either engine, does not average multiple iterations, and includes the full JS-to-WASM marshalling cost inside the WASM timer when typed arrays or primitives cross the boundary. Treat results as directional signals, not production SLA benchmarks.

Microbenchmarks Overstate Simplicity

Isolated loops in a tight harness favor whichever runtime executes the inner loop fastest. Real applications interleave compute with allocation, garbage collection, rendering, and event handling. A 40% win on Fibonacci(35) does not translate to a 40% win on page load.

Boundary Crossing Has a Cost

Every wasm_bindgen export that accepts or returns JavaScript values may copy data across the linear memory boundary. Sorting an array in WASM still requires a typed-array view; prime checking passes a single integer cheaply, but batch workloads that shuttle large objects back and forth can erase WASM's compute advantage entirely.

JIT Warmup Favors JavaScript on Short Runs

V8, SpiderMonkey, and JavaScriptCore optimize functions after repeated execution. A single timed invocation — exactly what the demo performs — often catches JavaScript before tiered compilation completes. Run the same function in a loop for several seconds and the gap may narrow or invert for some workloads. WASM starts fast because it ships as precompiled bytecode; JavaScript starts flexible but cold.

Interpreting the Three Benchmarks

WASM tends to win
  • Prime checking at large inputs — sustained integer arithmetic with minimal allocation; Rust's compiled loop runs without GC pauses.
  • Sorting large typed arrayssort_unstable over contiguous memory; engines still compete, but WASM avoids JS callback overhead per comparison when implemented natively.
JavaScript stays competitive
  • Small inputs — boundary setup dominates; a prime check on numbers below 10,000 may show JS winning simply because the call is too cheap to amortize WASM entry.
  • Recursive Fibonacci — both implementations use the same exponential algorithm; differences reflect call overhead and engine-specific recursion limits, not a fundamental WASM advantage. An iterative JS version would outperform both.

Increase the input slider on each demo page and run several trials. You should see prime checking and sorting widen in WASM's favor as work per call grows. Fibonacci remains a cautionary tale: the algorithm is the bottleneck, not the runtime.

When WebAssembly Earns Its Keep

Reach for WASM when the browser must execute substantial CPU-bound work on the main thread or in a dedicated worker:

  • Media and signal processing — audio codecs, image filters, video frame transforms, cryptography.
  • Numeric simulation — physics engines, geospatial calculations, compression and decompression of large payloads.
  • Portable native code — reusing existing C/C++/Rust libraries without rewriting them in JavaScript.
  • Predictable latency — workloads that cannot tolerate garbage-collection pauses during a critical path.

In these domains, WASM's ahead-of-time compilation and linear memory model deliver consistent throughput that JavaScript struggles to match, especially over long-running sessions.

When JavaScript Is the Better Default

Most front-end code should remain JavaScript or TypeScript. The ecosystem tooling — debugging, tree shaking, type checking, framework integration — is mature. JavaScript excels at orchestrating UI, handling events, and calling browser APIs that WASM cannot access directly without JS glue anyway.

  • DOM manipulation, routing, form validation, and state management gain nothing from WASM.
  • I/O-bound operations — REST calls, WebSocket streams, IndexedDB — are limited by the network or storage layer, not compute.
  • Small helper functions called frequently with tiny payloads pay more in boundary tax than they save in execution time.
  • Teams without Rust or C expertise face higher build complexity (wasm-pack, memory ownership, debugging source maps) for uncertain gain.

Profile first. If Chrome DevTools or the Performance panel shows your hot path is a JavaScript function executing for milliseconds on realistic data, then experiment with WASM. If the flame chart is dominated by layout, paint, or idle waiting, WASM will not help.

Build and Integration Notes

The repository wires Rust into Next.js with a two-step build: npm run wasm invokes wasm-pack build --target web, then npm run build copies the generated module into public/wasm for client-side import. The PerformanceTest component keeps each benchmark self-contained — swap the JS and WASM function props, provide an input generator, and the chart updates automatically.

For production systems, consider loading WASM modules lazily, reusing a single module instance across calls, keeping data in typed arrays to minimize copies, and running heavy work inside a Web Worker so the main thread stays responsive. Measure end-to-end latency, not just the inner loop.

Try the interactive demo

Clone the repository, run npm run build:all, and compare timings locally — or use the hosted demo linked above. Adjust input sizes, run multiple trials, and inspect the Rust and JavaScript source side by side. Full setup instructions and source are in learn_wasm-performance-tests.

Summary

WebAssembly is a powerful tool for CPU-bound, allocation-sensitive compute in the browser — not a drop-in replacement for JavaScript. Our Fibonacci, prime, and sorting benchmarks illustrate that the winning runtime depends on input size, algorithm choice, and how often you cross the JS/WASM boundary. Use WASM where native-speed numeric or media work justifies the integration cost; keep JavaScript for everything else until profiling proves otherwise.