ES Modules Logo ESModules.com

WebAssembly & ESM

WebAssembly brings near-native performance to the web. Learn how to integrate WASM with ES Modules using streaming compilation, source phase imports, and modern toolchains.

Current WASM + ESM Integration

Today, loading WebAssembly in an ES Module project uses the WebAssembly API. The recommended approach is instantiateStreaming(), which compiles and instantiates in a single step while streaming the binary:

// math.js - ESM wrapper for a WASM module
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('./math.wasm')
);

// Export WASM functions as ESM exports
export const add = wasmModule.instance.exports.add;
export const multiply = wasmModule.instance.exports.multiply;

For more control, you can separate the compile and instantiate steps. This is useful when you need to pass imports to the WASM module or instantiate the same module multiple times:

// Advanced: fetch, compile, then instantiate with imports
const response = await fetch('./graphics.wasm');
const bytes = await response.arrayBuffer();
const compiled = await WebAssembly.compile(bytes);

// Pass JavaScript functions into WASM
const instance = await WebAssembly.instantiate(compiled, {
  env: {
    log: (value) => console.log('WASM says:', value),
    memory: new WebAssembly.Memory({ initial: 256 })
  }
});

export const render = instance.exports.render;
export const resize = instance.exports.resize;

Why instantiateStreaming?

WebAssembly.instantiateStreaming() is the most efficient way to load WASM. It compiles the binary while it is still downloading, avoiding the overhead of buffering the entire file first. Always prefer it over compile() + instantiate() when you do not need to pass custom imports.

Using WASM functions from an ESM consumer is seamless once wrapped:

// app.js - consuming the WASM-backed ESM module
import { add, multiply } from './math.js';

console.log(add(40, 2));       // 42
console.log(multiply(6, 7));   // 42

// The consumer does not know or care that
// the implementation uses WebAssembly

Source Phase Imports (Stage 3)

Source Phase Imports (TC39 Stage 3) introduce a new import source syntax that gives you a compiled WebAssembly.Module directly, without needing fetch or compile calls:

// Source phase import - returns a WebAssembly.Module
import source mathModule from './math.wasm';

// The module is already compiled, just instantiate it
const instance = await WebAssembly.instantiate(mathModule, {
  // Optional imports object
});

export const add = instance.exports.add;
export const multiply = instance.exports.multiply;

This dramatically simplifies WASM loading compared to the fetch + compile pattern. The runtime handles fetching, streaming compilation, and caching automatically:

// Before: manual fetch and compile
const response = await fetch('./math.wasm');
const bytes = await response.arrayBuffer();
const module = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(module);

// After: source phase import handles it all
import source mathModule from './math.wasm';
const instance = await WebAssembly.instantiate(mathModule);

Runtime Support:

  • Chrome V8 M131+ (available behind flag, shipping in Chrome 131+)
  • Deno 2.6+ (native support)
  • Node.js - implementation in progress
  • Firefox and Safari - not yet implemented

Source phase imports also enable sharing a compiled module across multiple instantiations, which is useful for worker-based parallelism:

// Share one compiled module across multiple workers
import source imageProcessor from './image-processor.wasm';

// Each worker gets its own instance with separate memory
const worker1 = await WebAssembly.instantiate(imageProcessor, {
  env: { memory: new WebAssembly.Memory({ initial: 256 }) }
});

const worker2 = await WebAssembly.instantiate(imageProcessor, {
  env: { memory: new WebAssembly.Memory({ initial: 256 }) }
});

Toolchains

wasm-pack (Rust to WASM)

wasm-pack compiles Rust code to WebAssembly and generates ESM-compatible JavaScript bindings automatically. It is the most mature WASM toolchain:

// src/lib.rs - Rust source
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}
# Build with ESM output target
wasm-pack build --target web

# Generated output:
# pkg/
#   my_crate.js        <-- ESM wrapper (auto-generated)
#   my_crate_bg.wasm   <-- Compiled WASM binary
#   my_crate.d.ts      <-- TypeScript declarations
// Import the generated ESM wrapper
import init, { fibonacci } from './pkg/my_crate.js';

// Initialize the WASM module (required once)
await init();

console.log(fibonacci(10)); // 55

AssemblyScript (TypeScript-like to WASM)

AssemblyScript uses a TypeScript-like syntax that compiles directly to WebAssembly. It is the easiest entry point for JavaScript developers:

// assembly/index.ts - AssemblyScript source
export function add(a: i32, b: i32): i32 {
  return a + b;
}

export function isPrime(n: i32): bool {
  if (n < 2) return false;
  for (let i: i32 = 2; i * i <= n; i++) {
    if (n % i === 0) return false;
  }
  return true;
}
# Compile with ESM bindings
npx asc assembly/index.ts --outFile build/math.wasm \
  --bindings esm --optimize
// Import the generated ESM bindings
import { add, isPrime } from './build/math.js';

console.log(add(2, 3));      // 5
console.log(isPrime(17));     // true

Emscripten (C/C++ to WASM)

Emscripten compiles C and C++ code to WebAssembly with full standard library support. Use -sEXPORT_ES6=1 to generate ESM output:

// math.c - C source
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}
# Compile with ESM output
emcc math.c -o math.js \
  -sEXPORT_ES6=1 \
  -sMODULARIZE=1 \
  -sEXPORTED_FUNCTIONS='["_factorial"]' \
  -sEXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
// Import the ESM module generated by Emscripten
import createModule from './math.js';

const Module = await createModule();
const factorial = Module.cwrap('factorial', 'number', ['number']);

console.log(factorial(10)); // 3628800

Choosing a Toolchain:

Use wasm-pack for Rust projects with the best ESM integration and type safety. Use AssemblyScript for the lowest learning curve if you know TypeScript. Use Emscripten when porting existing C/C++ libraries or when you need full POSIX support.

Use Cases

WebAssembly excels where JavaScript hits performance limits. Combined with ES Modules, you get a clean integration boundary between high-performance WASM code and your JavaScript application:

Performance-Critical Computation

Physics simulations, pathfinding algorithms, data compression, and heavy number crunching. WASM runs at near-native speed - typically 1.5-2x slower than native C, but 10-100x faster than equivalent JavaScript.

Cryptography

Hashing, encryption, and digital signatures. Libraries like libsodium compile to WASM for constant-time operations that are harder to achieve in JavaScript due to JIT optimization variability.

Image & Video Processing

Real-time filters, format conversion, and compression. FFmpeg compiled to WASM enables browser-native video transcoding. Sharp and Squoosh use WASM for image optimization.

Gaming

Game engines like Unity and Unreal export to WASM. Physics engines, collision detection, and rendering pipelines benefit from predictable WASM performance without GC pauses.

AI/ML Inference in Browser

Run machine learning models client-side using WASM backends. ONNX Runtime Web and TensorFlow.js use WASM for CPU inference when WebGPU is not available.

Databases & Parsers

SQLite compiled to WASM (sql.js) runs full SQL queries in the browser. PDF parsers, markdown renderers, and syntax highlighters all benefit from WASM performance.

A common pattern is to use WASM for the hot path and JavaScript for everything else:

// image-editor.js - WASM for heavy lifting, JS for UI
import { applyFilter, resize } from './image-wasm.js';

export async function processImage(imageData, options) {
  // JavaScript handles UI logic and validation
  if (!imageData || imageData.length === 0) {
    throw new Error('No image data provided');
  }

  // WASM handles the performance-critical work
  const filtered = applyFilter(imageData, options.filter);
  const resized = resize(filtered, options.width, options.height);

  // JavaScript handles the result
  return new Blob([resized], { type: 'image/png' });
}

Browser & Runtime Support

Feature Chrome Firefox Safari Node.js Deno
WebAssembly Core 57+ 52+ 11+ 8+ 1.0+
instantiateStreaming() 61+ 58+ 15+ N/A (no fetch) 1.0+
Source Phase Imports V8 M131+ Not yet Not yet In progress 2.6+
WASM Threads 74+ 79+ 14.1+ 16+ 1.0+
WASM SIMD 91+ 89+ 16.4+ 16+ 1.0+
WASM GC 119+ 120+ Not yet Experimental 1.38+

WASM in Node.js:

Node.js does not have a native fetch() for local files, so instantiateStreaming() does not apply. Instead, read the .wasm file with fs.readFile() and use WebAssembly.instantiate(buffer). Source phase imports will make this seamless once shipped.

Frequently Asked Questions