← Back to DevBytes

WebAssembly: Running Non-Web Code in the Browser

Introduction to WebAssembly

WebAssembly (often abbreviated as Wasm) is a binary instruction format for a stack-based virtual machine. It is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications. In simpler terms, WebAssembly allows you to run code written in languages like C, C++, Rust, Go, and even Python directly inside the browser at near-native speeds.

Before WebAssembly, JavaScript was the only programming language that ran natively in the browser. While JavaScript engines have become incredibly fast over the years, certain workloads — such as video processing, 3D rendering, cryptography, scientific simulations, and heavy data manipulation — still benefit from the performance characteristics of lower-level, statically typed languages. WebAssembly fills that gap.

Why WebAssembly Matters

WebAssembly is not a replacement for JavaScript. Instead, it is a complement. The two technologies are designed to work together, with JavaScript handling DOM manipulation and user interactions, while WebAssembly handles the computationally expensive parts of an application. Here are the key reasons WebAssembly matters:

How WebAssembly Works

At a high level, the WebAssembly workflow looks like this: you write source code in a supported language, compile it to a .wasm binary file, and then load and execute that binary from JavaScript. The browser's WebAssembly engine compiles the binary to native machine code and executes it. Because the binary is already in a low-level format, this compilation step is extremely fast.

A WebAssembly module exports functions and memory that JavaScript can call and access. Conversely, JavaScript can pass functions into WebAssembly modules so that the compiled code can call back into JavaScript. This two-way communication is what makes WebAssembly practical for real-world applications.

Setting Up Your First WebAssembly Project

To demonstrate WebAssembly in action, we will write a simple function in Rust, compile it to WebAssembly, and call it from JavaScript. Rust is one of the most popular languages for WebAssembly development because of its excellent tooling support and memory safety guarantees.

Prerequisites

Before you begin, make sure you have the following installed on your system:

You can install wasm-pack with the following command:

curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh


Next, add the WebAssembly compilation target to your Rust toolchain:

rustup target add wasm32-unknown-unknown

Creating the Rust Project

Create a new Rust library project using Cargo:

cargo new --lib wasm-demo
cd wasm-demo

Open the Cargo.toml file and add the following configuration to expose the library as a WebAssembly-compatible package:

[package]
name = "wasm-demo"
version = "0.1.0"
edition = "2021"

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

[dependencies]
wasm-bindgen = "0.2"

The wasm-bindgen crate is the key dependency here. It provides a bridge between Rust and JavaScript, allowing you to export Rust functions to JavaScript and import JavaScript functions into Rust.

Now, replace the contents of src/lib.rs with the following code:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    if n <= 1 {
        return n as u64;
    }
    let mut a: u64 = 0;
    let mut b: u64 = 1;
    for _ in 2..=n {
        let temp = a + b;
        a = b;
        b = temp;
    }
    b
}

#[wasm_bindgen]
pub fn reverse_string(s: &str) -> String {
    s.chars().rev().collect()
}

This code defines three exported functions: a simple add function, a fibonacci calculator that demonstrates a compute-heavy operation, and a reverse_string function that shows how strings can be passed between JavaScript and Rust.

Building the WebAssembly Module

With the Rust code in place, build the WebAssembly package using wasm-pack:

wasm-pack build --target web

This command compiles your Rust code to a .wasm file and generates JavaScript glue code in a pkg directory. The --target web flag tells wasm-pack to generate output suitable for direct use in the browser via ES modules.

Creating the HTML and JavaScript Frontend

Create an index.html file in the project root with the following content:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebAssembly Demo</title>
  <style>
    body { font-family: sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
    .result { background: #f4f4f4; padding: 12px; border-radius: 6px; margin: 10px 0; }
    button { padding: 8px 16px; margin: 5px 0; cursor: pointer; }
  </style>
</head>
<body>
  <h1>WebAssembly Demo</h1>

  <h2>Addition</h2>
  <input type="number" id="numA" value="5">
  <input type="number" id="numB" value="7">
  <button id="addBtn">Add</button>
  <div class="result" id="addResult">Result: </div>

  <h2>Fibonacci</h2>
  <input type="number" id="fibN" value="40">
  <button id="fibBtn">Calculate</button>
  <div class="result" id="fibResult">Result: </div>

  <h2>Reverse String</h2>
  <input type="text" id="strInput" value="Hello, WebAssembly!">
  <button id="revBtn">Reverse</button>
  <div class="result" id="revResult">Result: </div>

  <script type="module">
    import init, { add, fibonacci, reverse_string } from './pkg/wasm_demo.js';

    async function run() {
      await init();

      document.getElementById('addBtn').addEventListener('click', () => {
        const a = parseInt(document.getElementById('numA').value);
        const b = parseInt(document.getElementById('numB').value);
        const result = add(a, b);
        document.getElementById('addResult').textContent = 'Result: ' + result;
      });

      document.getElementById('fibBtn').addEventListener('click', () => {
        const n = parseInt(document.getElementById('fibN').value);
        const start = performance.now();
        const result = fibonacci(n);
        const elapsed = performance.now() - start;
        document.getElementById('fibResult').textContent =
          'Result: ' + result + ' (computed in ' + elapsed.toFixed(2) + ' ms)';
      });

      document.getElementById('revBtn').addEventListener('click', () => {
        const s = document.getElementById('strInput').value;
        const result = reverse_string(s);
        document.getElementById('revResult').textContent = 'Result: ' + result;
      });
    }

    run();
  </script>
</body>
</html>

Notice how the JavaScript code imports the init function along with the exported Rust functions. The init function asynchronously loads and instantiates the WebAssembly module before any exported functions can be called. Once initialized, calling the Rust functions feels just like calling ordinary JavaScript functions.

Running the Demo

Because WebAssembly modules and ES modules require a proper HTTP server (they cannot be loaded directly from the filesystem due to CORS restrictions), you need to serve the project locally. If you have Python installed, you can run:

python3 -m http.server 8080

Alternatively, if you have Node.js, you can use npx to run a quick server:

npx serve .

Open your browser and navigate to http://localhost:8080. You should see the demo page with working buttons. Try calculating a large Fibonacci number and observe how quickly WebAssembly computes the result.

Loading WebAssembly Manually Without Frameworks

While wasm-pack and wasm-bindgen provide a convenient developer experience, it is important to understand what happens under the hood. You can load a raw .wasm file directly using the JavaScript WebAssembly API. Here is an example:

// Assume you have a compiled file called "module.wasm"
// that exports a function called "multiply".

async function loadWasm() {
  // Fetch the WebAssembly binary
  const response = await fetch('module.wasm');
  const bytes = await response.arrayBuffer();

  // Instantiate the module
  const { instance } = await WebAssembly.instantiate(bytes, {
    env: {
      // Imported functions that the wasm module can call
      log: (value) => console.log('Wasm says:', value)
    }
  });

  // Call an exported function
  const result = instance.exports.multiply(6, 7);
  console.log('6 * 7 =', result);
  return instance;
}

loadWasm().catch(console.error);

The second argument to WebAssembly.instantiate is an imports object. This object provides functions and memory that the WebAssembly module expects to be available at runtime. This is how WebAssembly modules can call back into JavaScript for tasks like console logging, DOM access, or network requests.

Working with WebAssembly Memory

WebAssembly modules have their own linear memory, which is a contiguous block of bytes that can be grown dynamically. When you pass complex data like strings or arrays between JavaScript and WebAssembly, you are actually writing data into this shared memory buffer. Here is a simplified example of how memory sharing works:

async function runWithMemory() {
  const response = await fetch('module.wasm');
  const bytes = await response.arrayBuffer();

  const memory = new WebAssembly.Memory({ initial: 1 });

  const { instance } = await WebAssembly.instantiate(bytes, {
    env: { memory }
  });

  // Write a string into WebAssembly memory
  const message = 'Hello from JavaScript!';
  const encoder = new TextEncoder();
  const encoded = encoder.encode(message);

  // Get a view into the memory buffer
  const buffer = new Uint8Array(memory.buffer);

  // Write the encoded string at offset 0
  buffer.set(encoded, 0);

  // Call a wasm function that reads from offset 0
  // and returns the length of the string it processed
  const length = instance.exports.process_string(0, encoded.length);
  console.log('Processed string of length:', length);
}

In practice, tools like wasm-bindgen handle all of this memory marshalling automatically, so you rarely need to manage it manually. However, understanding the underlying mechanism is valuable when debugging performance issues or working with languages that do not have mature WebAssembly bindings.

Using WebAssembly with C and C++

Rust is not the only option for WebAssembly. C and C++ have excellent support through the Emscripten compiler. Emscripten is a complete compiler toolchain that can compile C and C++ code to WebAssembly, along with providing polyfills for common system APIs like file I/O, OpenGL (via WebGL), and SDL.

To get started with Emscripten, install the SDK:

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

Here is a simple C program that we will compile to WebAssembly:

// math.c
#include <emscripten/emscripten.h>

EMSCRIPTEN_KEEPALIVE
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

EMSCRIPTEN_KEEPALIVE
int gcd(int a, int b) {
    while (b != 0) {
        int temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

The EMSCRIPTEN_KEEPALIVE macro tells the compiler not to dead-code-eliminate these functions, ensuring they are exported and callable from JavaScript. Compile the program with the following command:

emcc math.c -o math.js \
  -s WASM=1 \
  -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' \
  -s EXPORTED_FUNCTIONS='["_factorial", "_gcd"]'

This produces two files: math.wasm (the binary module) and math.js (the JavaScript glue code). You can then use them in an HTML file like this:

<!DOCTYPE html>
<html>
<body>
  <script src="math.js"></script>
  <script>
    Module.onRuntimeInitialized = function() {
      // Use cwrap to create a callable wrapper
      const factorial = Module.cwrap('factorial', 'number', ['number']);
      const gcd = Module.cwrap('gcd', 'number', ['number', 'number']);

      console.log('factorial(5) =', factorial(5));
      console.log('gcd(48, 36) =', gcd(48, 36));
    };
  </script>
</body>
</html>

The Module.onRuntimeInitialized callback fires once the Emscripten runtime and WebAssembly module are fully loaded. The cwrap function creates a JavaScript wrapper around the exported C function, specifying the return type and argument types so that values are converted correctly.

Best Practices for WebAssembly Development

Use WebAssembly for the Right Tasks

WebAssembly shines in CPU-intensive scenarios: image and video processing, audio effects, physics simulations, cryptography, parsing large datasets, and game engines. It is not beneficial for DOM manipulation, event handling, or simple UI logic. Use JavaScript for what it does well and reserve WebAssembly for performance-critical hotspots.

Minimize Cross-Boundary Calls

Every time JavaScript calls a WebAssembly function (or vice versa), there is a small overhead. While individual calls are fast, making thousands of calls per frame can add up. Design your API so that the WebAssembly module does as much work as possible in a single call, rather than requiring many small round trips. For example, instead of calling a function to process each pixel of an image individually, pass the entire image buffer and process it in one call.

Optimize Your Binary Size

WebAssembly binaries are downloaded over the network, so size matters. In Rust, you can reduce binary size by adding optimization flags to your Cargo.toml:

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

For C and C++ with Emscripten, use -Oz for size optimization and consider using --closure-config to minify the generated JavaScript glue code.

Handle Errors Gracefully

WebAssembly modules cannot throw JavaScript exceptions directly. In Rust, panics are caught and converted to JavaScript errors, but it is better to use Result types and handle errors explicitly. In C and C++, you should return error codes rather than relying on exceptions, since exception support in WebAssembly adds significant binary size.

Provide a Loading Experience

WebAssembly modules must be downloaded and instantiated before they can be used. For large modules, this can take noticeable time. Show a loading indicator while the module initializes, and consider code-splitting so that only the modules needed for the current page are loaded. You can also use WebAssembly.instantiateStreaming instead of WebAssembly.instantiate for better performance, as it streams and compiles the module in parallel with the download:

const { instance } = await WebAssembly.instantiateStreaming(
  fetch('module.wasm'),
  importObject
);

Test Across Browsers

While WebAssembly is supported in all modern browsers, there can be subtle differences in performance characteristics and feature support. Test your application in Chrome, Firefox, Safari, and Edge. Pay special attention to Safari on iOS, as it sometimes lags behind desktop browsers in adopting newer WebAssembly features like SIMD and reference types.

Consider WebAssembly System Interface (WASI)

If you plan to run WebAssembly outside the browser, look into WASI. WASI is a standardized interface for WebAssembly modules that provides access to system resources like files, network sockets, and the clock in a secure, capability-based manner. Runtimes like Wasmtime and WasmEdge implement WASI, allowing you to run the same WebAssembly modules on servers, edge networks, and embedded devices.

Real-World Use Cases

WebAssembly is already used in production by many major projects. Here are some notable examples:

  • Figma: The popular design tool uses WebAssembly (compiled from C++) to render complex vector graphics in the browser at high performance.
  • Google Earth: Google Earth for the web runs its 3D rendering engine, originally written in C++, as a WebAssembly module.
  • AutoCAD Web: Autodesk ported decades of C++ codebase to the web using WebAssembly, enabling users to view and edit CAD drawings in the browser.
  • Photoshop on the web: Adobe brought Photoshop to the browser by compiling significant portions of its desktop C++ codebase to WebAssembly.
  • Pyodide: This project compiles the CPython interpreter to WebAssembly, allowing Python code to run in the browser with access to many scientific computing libraries.

Conclusion

WebAssembly has fundamentally expanded what is possible on the web. By allowing developers to run code written in languages like Rust, C, and C++ at near-native speeds inside the browser, it opens the door to building complex, performance-critical applications that were previously limited to native platforms. Whether you are porting an existing codebase, optimizing a computationally heavy feature, or exploring server-side and edge computing use cases with WASI, WebAssembly provides a powerful, safe, and portable execution environment. As the ecosystem continues to mature with improvements like garbage collection support, SIMD instructions, and component models, WebAssembly will only become more central to modern web development. Start small by identifying a performance bottleneck in your application, prototype a WebAssembly solution, and measure the results — you may be surprised by how much headroom there is when you step outside the JavaScript runtime.

— Ad —

Google AdSense will appear here after approval

← Back to all articles