WebAssembly and JS Interop

Reviewed & published by Brayan K

WebAssembly (wasm) is a low-level binary instruction format that runs at near-native speed in the browser, letting you compile languages like C, C++, and Rust to code that works alongside JavaScript for performance-critical tasks.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: Building wasm requires a toolchain. The JavaScript glue below is illustrative. For the best experience:

🏎️ Real-World Analogy: WebAssembly is like a race-car engine dropped into your web app:

WebAssembly is a compact binary format that browsers can decode and execute far faster than they can parse and optimize equivalent JavaScript. It is a compilation target: you write in C, C++, Rust, or other languages and compile down to a .wasm module. That module runs in the same secure sandbox as JavaScript, on the same engine, and the two interoperate through a small, well-defined boundary of imports and exports.

ConceptMeaningKey API
ModuleCompiled, stateless wasm codeWebAssembly.Module
InstanceA running module with its memoryinstance.exports
Linear MemoryResizable raw byte bufferWebAssembly.Memory

🔥 What wasm Is (and Isn't)

A .wasm file holds bytecode for a stack-based virtual machine. It has a readable text format (.wat) for debugging, but ships as binary. Crucially, wasm has no built-in DOM access — it computes, and calls into JavaScript when it needs to touch the page.

if (typeof WebAssembly === "object") {
  console.log("WebAssembly is supported!");
  console.log("instantiate:", typeof WebAssembly.instantiate);
  console.log("instantiateStreaming:", typeof WebAssembly.instantiateStreaming);
  console.log("compile:", typeof WebAssembly.compile);
} else {
  console.log("This environment has no WebAssembly support");
}

All modern browsers ship WebAssembly. It is a web standard alongside HTML, CSS, and JavaScript.

🔥 Compiling from C and Rust

You rarely write wasm by hand. Toolchains compile high-level code: Emscripten for C/C++ and wasm-pack (with wasm-bindgen) for Rust. Functions marked for export become callable from JavaScript.

// lib.rs (compiled with: wasm-pack build --target web)
// use wasm_bindgen::prelude::*;
//
// #[wasm_bindgen]
// pub fn fibonacci(n: u32) -> u32 {
//     if n < 2 { return n; }
//     let mut a = 0;
//     let mut b = 1;
//     for _ in 2..=n {
//         let next = a + b;
//         a = b;
//         b = next;
//     }
//     b
// }

// The equivalent JS, for reference - wasm runs this far faster:
function fibonacci(n) {
  if (n < 2) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) { [a, b] = [b, a + b]; }
  return b;
}
console.log("fibonacci(20) =", fibonacci(20));

For C/C++, Emscripten produces a .wasm file plus optional JavaScript glue. Either way, the result is a portable module you load from JavaScript.

🔥 Instantiating a wasm Module

To run wasm you compile and instantiate it. WebAssembly.instantiateStreaming() does both directly from a fetch response and is the most efficient path. The older WebAssembly.instantiate() works from an ArrayBuffer.

async function loadWasm() {
  // Streams, compiles, and instantiates in one step
  const { instance } = await WebAssembly.instantiateStreaming(
    fetch("/math.wasm")
  );

  // Exported functions live on instance.exports
  const { fibonacci } = instance.exports;
  console.log("fibonacci(20) from wasm:", fibonacci(20));
}

// Fallback for servers that don't send the wasm MIME type:
async function loadWasmFallback() {
  const response = await fetch("/math.wasm");
  const bytes = await response.arrayBuffer();
  const { instance } = await WebAssembly.instantiate(bytes);
  return instance.exports;
}

console.log("Use instantiateStreaming when possible");

Streaming compiles the module as the bytes arrive, so execution can start sooner. Use the arrayBuffer fallback if your server does not serve application/wasm.

🔥 Worked Example: Run Real wasm, Right Here

Everything above needs a server and a build toolchain. This one does not. A .wasm file is just bytes, and you are allowed to hand those bytes to WebAssembly directly — no fetch, no Emscripten, no wasm-pack. The 41 bytes below are a complete, valid WebAssembly module that exports one function, add. Press Run and you are executing real WebAssembly in your browser.

Every byte is commented with the section it belongs to, so you can see the shape of the binary format instead of taking it on trust.

// A complete .wasm module, written out byte by byte.
// This is the same binary a compiler would produce for:
//   (module (func (export "add") (param i32 i32) (result i32)
//     local.get 0  local.get 1  i32.add))

const wasmBytes = new Uint8Array([
  // --- Header: the magic number "\0asm" and format version 1 ---
  0x00, 0x61, 0x73, 0x6d,   0x01, 0x00, 0x00, 0x00,

  // --- Type section (id 1): declares one function signature ---
  // 0x60 = "func type", 2 params of 0x7f (i32), 1 result of 0x7f (i32)
  0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,

  // --- Function section (id 3): one function, using type 0 ---
  0x03, 0x02, 0x01, 0x00,

  // --- Export section (id 7): export it under the name "add" ---
  // 0x61 0x64 0x64 are the ASCII codes for a, d, d
  0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,

  // --- Code section (id 10 = 0x0a): the actual instructions ---
  // 0x20 0x00 = local.get 0   (push the first parameter)
  // 0x20 0x01 = local.get 1   (push the second parameter)
  // 0x6a      = i32.add       (pop two, push the sum)
  // 0x0b      = end
  0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b
]);

console.log("module size:", wasmBytes.length, "bytes");

// Bytes 1-3 spell "asm" — that is how the engine recognises the format.
console.log("magic header:", String.fromCharCode(wasmBytes[1], wasmBytes[2], wasmBytes[3]));

// Compile the bytes into a Module (stateless compiled code)...
// (Named wasmModule, not "module" — "module" is a reserved name in Node
//  and in bundlers, and this snippet should paste anywhere.)
const wasmModule = new WebAssembly.Module(wasmBytes);

// ...then create an Instance (a running copy with its own state).
const instance = new WebAssembly.Instance(wasmModule);

// Exports come out as ordinary JavaScript functions.
const { add } = instance.exports;

console.log("typeof add:", typeof add);
console.log("add(2, 3) =", add(2, 3));
console.log("add(21, 21) =", add(21, 21));

// The parameters were declared i32 — 32-bit integers. Hand it decimals and
// they are truncated toward zero on the way in: 2.9 -> 2, 3.9 -> 3.
console.log("add(2.9, 3.9) =", add(2.9, 3.9));

console.log("exported names:", Object.keys(instance.exports).join(","));

// ✅ Expected output:
// module size: 41 bytes
// magic header: asm
// typeof add: function
// add(2, 3) = 5
// add(21, 21) = 42
// add(2.9, 3.9) = 5
// exported names: add

Two objects did all the work. WebAssembly.Module is the compiled code, with no state of its own — you can create many instances from one module. WebAssembly.Instance is a live copy with its own memory and its own exports. instantiateStreaming() from the previous section is simply those two steps done in one call, fed by a network response.

🎯 Your Turn: Change What the Module Does

Now edit a WebAssembly binary by hand. Two of the blanks are the API calls you just met; the third is a single opcode byte inside the code section. Change it and the exported function changes behaviour — which is the clearest possible demonstration that the export name is only a label, and the bytes are what actually run.

// 🎯 YOUR TURN — compile the module, then change one byte
// Replace each ___ then press Run.

const wasmBytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6d,   0x01, 0x00, 0x00, 0x00,     // header
  0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type section
  0x03, 0x02, 0x01, 0x00,                               // function section
  0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export section: "add"

  // Code section. The second-to-last byte is the instruction that runs:
  //   0x6a = i32.add     0x6b = i32.sub     0x6c = i32.mul
  0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, ___, 0x0b
  //                                                     👆 make it MULTIPLY
]);

// 1) Compile the bytes into stateless compiled code
const wasmModule = new WebAssembly.___(wasmBytes);   // 👉 which WebAssembly class compiles bytes?

// 2) Create a live copy with its own state
const instance = new WebAssembly.___(wasmModule);    // 👉 which one runs a module?

const op = instance.exports.add;

// The export section still spells "add" — a name is just a label. The byte
// you changed is what the function actually does.
console.log("exported name is still:", Object.keys(instance.exports).join(","));
console.log("op(2, 3) =", op(2, 3));
console.log("op(6, 7) =", op(6, 7));

// ✅ Expected output:
// exported name is still: add
// op(2, 3) = 6
// op(6, 7) = 42

🔥 Linear Memory

A wasm instance owns a linear memory: a single, contiguous, resizable ArrayBuffer of raw bytes. Both wasm and JavaScript can read and write it through typed arrays, which is how you exchange strings, arrays, and structs across the boundary.

// Create a memory: 1 page = 64 KiB
const memory = new WebAssembly.Memory({ initial: 1, maximum: 10 });

// Wrap the raw bytes in a typed array view
const view = new Uint8Array(memory.buffer);

// JS writes some bytes...
view[0] = 72;  // 'H'
view[1] = 105; // 'i'

// ...and reads them back (wasm could do the same)
console.log("byte[0]:", view[0], "byte[1]:", view[1]);
console.log("As text:", String.fromCharCode(view[0], view[1]));
console.log("Memory bytes:", memory.buffer.byteLength); // 65536

Because wasm's value types are just numbers, anything richer (text, images, objects) is passed by writing bytes into this shared memory and exchanging offsets and lengths.

🔥 Calling wasm from JS, and JS from wasm

Interop flows both ways. A module's exports are functions JavaScript can call. A module's imports are functions (and memory) JavaScript supplies, so the module can call back into JavaScript — useful since wasm cannot reach the DOM itself.

async function run() {
  // JS functions the wasm module is allowed to call
  const importObject = {
    env: {
      // wasm can call this to log, since it has no console itself
      log_value: (value) => console.log("wasm says:", value),
      // wasm can ask JS for the current time
      now: () => Date.now()
    }
  };

  const { instance } = await WebAssembly.instantiateStreaming(
    fetch("/app.wasm"),
    importObject
  );

  // Call a function the wasm module exported
  const result = instance.exports.compute(21);
  console.log("Export compute(21) returned:", result);
}

console.log("exports = JS calls wasm; imports = wasm calls JS");

This two-way boundary is the heart of interop: JavaScript orchestrates the app and the DOM, while wasm runs the heavy computation and calls back for anything it cannot do alone.

🔥 Use Cases and Near-Native Speed

Wasm shines for performance-critical, CPU-bound work: image and video processing, 3D and game engines, audio synthesis, physics and scientific simulation, cryptography, compression, and porting large existing C/C++/Rust codebases to the web.

const goodForWasm = [
  "Image/video processing (filters, transcoding)",
  "Games and 3D engines (Unity, Unreal export)",
  "Audio synthesis and DSP",
  "Physics and scientific simulations",
  "Cryptography and hashing",
  "Compression / decompression",
  "Porting existing C / C++ / Rust libraries"
];

const stillBetterInJS = [
  "DOM manipulation and event handling",
  "Network requests and app glue",
  "General UI logic"
];

console.log("Reach for wasm when:");
goodForWasm.forEach((u) => console.log("  -", u));
console.log("Keep in JS:");
stillBetterInJS.forEach((u) => console.log("  -", u));

Wasm runs at near-native speed because it is a compact, statically-typed format the engine can compile ahead of time, avoiding the parsing and dynamic optimization that JavaScript needs.

🔥 wasm Complements JavaScript

A common misconception is that wasm replaces JavaScript. It does not. Wasm has no DOM access and no garbage-collected high-level objects in its core; JavaScript remains the language of the web platform. The two are designed to work together.

🎯 Mini-Challenge: Get a String Across the Boundary

This is the single most common piece of real wasm interop code, and the reason linear memory exists. A wasm function cannot take a string — it only speaks numbers — so text crosses by being written into the shared memory, with the two sides passing an offset and a length to each other. Write both halves yourself. Nothing is filled in; match the expected output and you have it.

// 🎯 MINI-CHALLENGE: pass a string across the wasm boundary
//
// wasm functions only take and return numbers. Text crosses the boundary by
// living in the shared linear memory while the two sides exchange an OFFSET
// and a LENGTH. Write both halves from scratch. Only the brief is here.
//
// 1. writeString(memory, offset, text)
//    - encode "text" to UTF-8 bytes with: new TextEncoder().encode(text)
//    - copy those bytes into memory.buffer starting at "offset"
//    - return how many BYTES were written
//
// 2. readString(memory, offset, length)
//    - take "length" bytes from memory.buffer starting at "offset"
//    - decode them back into a string with: new TextDecoder().decode(bytes)
//    - return that string
//
// Hints:
//   new Uint8Array(memory.buffer)  gives a view over the whole memory
//   view.set(bytes, offset)        copies bytes in at a position
//   view.subarray(start, end)      slices a view without copying

// your code here


// --- Do not change the code below: this is the test drive ---
const memory = new WebAssembly.Memory({ initial: 1 });

const len = writeString(memory, 64, "héllo wasm");
console.log("bytes written:", len);
console.log("read back:", readString(memory, 64, len));
console.log("memory size:", memory.buffer.byteLength);

const len2 = writeString(memory, 200, "second");
console.log("second string:", readString(memory, 200, len2));
console.log("first string still:", readString(memory, 64, len));

// Notice: 10 characters, 11 bytes. The é takes two bytes in UTF-8, which is
// exactly why the length you pass to wasm must be a byte count.

// ✅ Expected output:
// bytes written: 11
// read back: héllo wasm
// memory size: 65536
// second string: second
// first string still: héllo wasm

🎯 Key Takeaways

Practice quiz

What is WebAssembly (wasm)?

  • A low-level, binary instruction format that runs at near-native speed in the browser
  • A CSS framework
  • A package manager for JavaScript
  • A new version of JavaScript

Answer: A low-level, binary instruction format that runs at near-native speed in the browser. WebAssembly is a portable, low-level binary instruction format designed as a compilation target that runs at near-native speed in browsers and other hosts.

Which languages commonly compile to WebAssembly?

  • Only JavaScript
  • HTML and CSS
  • Systems languages like C, C++, and Rust
  • SQL

Answer: Systems languages like C, C++, and Rust. Languages such as C, C++, and Rust are commonly compiled to wasm using toolchains like Emscripten and wasm-pack.

What is the recommended way to compile and instantiate a .wasm module from a fetch response?

  • JSON.parse()
  • WebAssembly.instantiateStreaming()
  • require()
  • eval()

Answer: WebAssembly.instantiateStreaming(). WebAssembly.instantiateStreaming(fetch(...)) compiles and instantiates the module directly from the streamed response, which is the most efficient approach.

What does WebAssembly use for its memory model?

  • The DOM tree
  • Browser cookies
  • localStorage
  • A linear memory: a single resizable ArrayBuffer of raw bytes

Answer: A linear memory: a single resizable ArrayBuffer of raw bytes. Wasm uses linear memory, a contiguous, resizable ArrayBuffer of bytes that both wasm and JavaScript can read and write.

How does JavaScript call a function defined in a wasm module?

  • Through the module instance's exports object
  • By reading it from the DOM
  • Using document.querySelector
  • It cannot - wasm is isolated

Answer: Through the module instance's exports object. Functions a wasm module exports are available on instance.exports and can be called from JavaScript like normal functions.

How can a wasm module call a JavaScript function?

  • By using the fetch API directly
  • Through the imports object passed in at instantiation
  • By editing the HTML
  • It cannot call JavaScript at all

Answer: Through the imports object passed in at instantiation. You pass an imports object when instantiating; the wasm module declares matching imports and can call those provided JavaScript functions.

Which is a strong use case for WebAssembly?

  • Storing form values
  • Adding click handlers to a page
  • Styling buttons
  • Performance-critical code like image/video processing, games, or cryptography

Answer: Performance-critical code like image/video processing, games, or cryptography. Wasm excels at CPU-intensive workloads such as image/video processing, 3D games, audio, simulations, and cryptography where near-native speed matters.

What is the relationship between WebAssembly and JavaScript?

  • JavaScript runs inside wasm only
  • Wasm is meant to fully replace JavaScript
  • Wasm complements JavaScript; they work together, with JS handling glue and the DOM
  • They cannot be used in the same page

Answer: Wasm complements JavaScript; they work together, with JS handling glue and the DOM. Wasm complements rather than replaces JavaScript: JS typically orchestrates the app and DOM while wasm handles heavy computation.

Why can't wasm modules directly manipulate the DOM today?

  • Wasm has no concept of functions
  • Wasm has no built-in DOM access, so it calls into JavaScript to touch the DOM
  • The DOM is written in Rust
  • Wasm is too slow for the DOM

Answer: Wasm has no built-in DOM access, so it calls into JavaScript to touch the DOM. Core wasm has no direct DOM bindings, so DOM changes are performed by calling imported JavaScript functions that act as glue.

What Rust tool is commonly used to build wasm and generate JS bindings?

  • wasm-pack (with wasm-bindgen)
  • npm
  • webpack only
  • babel

Answer: wasm-pack (with wasm-bindgen). wasm-pack, together with wasm-bindgen, compiles Rust to wasm and generates the JavaScript bindings that ease interop.

Continue this course