NodeJSWebAssembly in Node.js

WebAssembly in Node.js

WebAssembly (Wasm) is a binary instruction format that V8 can execute at near-native speed, inside the same process as JavaScript but in a separate, sandboxed memory space. In Node.js you can load Wasm modules compiled from C, C++, Rust, Go, or AssemblyScript and call their exports as regular JavaScript functions. Compared to native addons, Wasm is portable (one binary runs on Linux, macOS, Windows, and in browsers), sandboxed (no direct OS access by default), and doesn't require a C++ toolchain on the end user's machine. It's the right choice for CPU-intensive computation that needs to be shared between Node and the browser, or for wrapping C/C++ code without the operational complexity of N-API.

Wasm vs native addons

Dimension

WebAssembly

Native Addon (N-API)

Portability

Single .wasm file runs everywhere V8 runs

Must compile per platform/arch/Node version

Sandboxing

Isolated linear memory; no direct OS access

Full OS access (same as Node process)

OS APIs

Requires WASI or JS imports to access filesystem/network

Direct system calls, file I/O, sockets

Performance

Near-native; some overhead for JS↔Wasm boundary calls

Native; minimal boundary overhead

Toolchain

Emscripten (C/C++) or wasm-pack (Rust) at build time only

C++ compiler required on every build machine

Debugging

Source maps + Chrome DevTools Wasm debugging

gdb/lldb with native symbol files

Best for

Compute-heavy algorithms, browser+Node sharing, safe sandboxing

OS API access, hardware drivers, existing native libs

Loading a .wasm file directly

TS
import fs from 'node:fs'
import path from 'node:path'

// Synchronous load (fine at startup, don't use in request handlers):
const wasmBuffer = fs.readFileSync(path.join(__dirname, 'math.wasm'))
const wasmModule = new WebAssembly.Module(wasmBuffer)
const instance   = new WebAssembly.Instance(wasmModule, {
  // imports: functions your Wasm module calls into JavaScript
  env: {
    log: (value: number) => console.log('Wasm log:', value),
  },
})

// Call exports (functions compiled into the .wasm):
const exports = instance.exports as { add: (a: number, b: number) => number }
console.log(exports.add(3, 4))   // 7

TS
// Asynchronous loading — preferred for large modules; non-blocking:
async function loadWasm(wasmPath: string) {
  const buffer = await fs.promises.readFile(wasmPath)
  const { instance } = await WebAssembly.instantiate(buffer, {
    env: { memory: new WebAssembly.Memory({ initial: 1 }) },
  })
  return instance
}
Compiling C to Wasm with Emscripten

Bash
# Install Emscripten SDK (one-time):
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk && ./emsdk install latest && ./emsdk activate latest && source ./emsdk_env.sh

# Compile a C file to Wasm:
emcc math.c -o math.js \
  -s EXPORTED_FUNCTIONS='["_add","_multiply"]' \
  -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
  -s MODULARIZE=1 \
  -s EXPORT_NAME=createMathModule

C
// math.c
#include <stdint.h>

int32_t add(int32_t a, int32_t b) { return a + b; }
int32_t multiply(int32_t a, int32_t b) { return a * b; }

TS
// Use the generated JS loader (Emscripten wraps the Wasm with a JS module):
const createMathModule = require('./math.js')
const Module = await createMathModule()

const add = Module.cwrap('add', 'number', ['number', 'number'])
console.log(add(6, 7))   // 42
Compiling Rust to Wasm with wasm-pack

Bash
# Install Rust and wasm-pack:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install wasm-pack

# Compile:
wasm-pack build --target nodejs   # generates pkg/ with .wasm + JS bindings + TypeScript types

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

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

TS
// After wasm-pack build --target nodejs:
const { fibonacci } = require('./pkg/my_crate')
console.log(fibonacci(40))   // fast, Rust-speed
wasm-pack generates TypeScript types from Rust — the pkg/ directory has a .d.ts file so you get full type checking on your Wasm exports
One of Rust + wasm-pack's key advantages is the ergonomics: `wasm_bindgen` generates JavaScript glue code and TypeScript declarations automatically. The consumer gets a normal npm-style import with types, no manual wrapping required. For Rust types that don't have direct JS equivalents (structs, enums), wasm-bindgen generates JavaScript wrapper classes. This makes Rust Wasm modules genuinely easy to use from TypeScript — you call them like any typed dependency. The build artifact (the `pkg/` directory) can be published to npm directly and consumed by both Node.js and browser applications.
Sharing memory between JS and Wasm

TS
// Wasm has its own linear memory (a resizable ArrayBuffer).
// To pass large data without copying, write directly into Wasm memory:

const memory = new WebAssembly.Memory({ initial: 10, maximum: 100 }) // pages × 64KB
const { instance } = await WebAssembly.instantiate(wasmBuffer, {
  env: { memory },
})

const exports = instance.exports as {
  processData: (ptr: number, length: number) => number
}

// Write data into Wasm's linear memory via a Uint8Array view:
const view = new Uint8Array(memory.buffer)
const inputData = new TextEncoder().encode('hello wasm')
view.set(inputData, 0)   // write at offset 0

// Call Wasm function with pointer (offset) and length:
const result = exports.processData(0, inputData.length)
Wasm memory is a flat byte array — passing strings or complex objects requires serialization (write to Wasm memory, pass pointer+length); there is no automatic marshaling
Unlike N-API which has `Napi::String`, `Napi::Object`, etc. that wrap V8 values, Wasm functions only accept and return numeric types (i32, i64, f32, f64). Passing a string to Wasm requires encoding it as bytes, writing those bytes into Wasm's linear memory, and passing the offset and length as integers. The Wasm function reads from its linear memory. The reverse (returning a string) requires the Wasm function to write to memory and return a pointer+length pair that your JS code reads back. Tools like `wasm-bindgen` (Rust) and Emscripten's `cwrap`/`ccall` automate this marshaling — use them rather than doing it manually.
WASI — WebAssembly System Interface

TS
import { WASI } from 'node:wasi'
import fs from 'node:fs'

// WASI gives a Wasm module controlled access to filesystem and other OS APIs:
const wasi = new WASI({
  version: 'preview1',
  args: process.argv,
  env: process.env,
  preopens: {
    '/sandbox': '/some/real/directory',  // map virtual path to real path
  },
})

const wasmBuffer = fs.readFileSync('cli-tool.wasm')
const { instance } = await WebAssembly.instantiate(wasmBuffer, {
  wasi_snapshot_preview1: wasi.wasiImport,
})

wasi.start(instance)   // run the Wasm module's _start / main function
WASI lets Wasm modules access the filesystem and environment variables within a sandbox — the preopens map controls exactly which paths are visible
WASI (WebAssembly System Interface) is a standardized API that gives Wasm modules access to OS primitives (filesystem, environment variables, clocks, random) without breaking the Wasm sandbox model. The key concept is **capability-based security**: a WASI Wasm module only has access to the filesytem paths explicitly listed in `preopens`. It can't access `/etc/passwd` or any other path unless you map it. This makes WASI Wasm an excellent sandboxing mechanism for running untrusted code (user-supplied plugins, isolated compute tasks) — more isolation than a native addon with full OS access, less overhead than a full container.
Next
Track async context and propagate request IDs across callbacks: [async_hooks](/nodejs/async-hooks).