Skip to main content

πŸ”Œ Using WASM in Web Apps

Understanding WebAssembly is one thing; wiring a module into a running app is another. In this lesson you'll load a .wasm module the modern way, call its exported functions from JavaScript, let it call back into your code through imports, and β€” the part that trips everyone up β€” pass strings and image data across the shared linear-memory boundary. By the end you'll have a mental blueprint for a real WASM-powered feature.

Week 14 · Day 5 (Friday: WebAssembly Basics) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Load and instantiate a WebAssembly module with WebAssembly.instantiateStreaming
  • Call exported WASM functions from JavaScript and pass callbacks to WASM through an import object
  • Explain the JS/WASM interop boundary and why numbers cross cheaply but strings and objects need marshalling
  • Read and write WebAssembly linear memory from JavaScript using typed-array views
  • Move an image buffer into WASM, process it, and copy the result back to a canvas
  • Integrate a WASM module into a framework component with a clean loading lifecycle

Estimated Time: 65 minutes

Practice: Build a memory-sharing routine and a grayscale image filter driven by WASM.

In This Lesson

Loading a Module

Before you can call anything, the browser must fetch the .wasm bytes, compile them to machine code, and instantiate the module (allocate its memory and wire up imports). The modern one-liner that does all three efficiently is WebAssembly.instantiateStreaming.

// Fetch, compile, and instantiate β€” all while the file is still
// downloading. This is the recommended path.
const { instance, module } = await WebAssembly.instantiateStreaming(
  fetch('math.wasm'),
  importObject   // optional β€” functions/values we give TO the module
);

// Everything the module exported lives here:
const { add, fibonacci } = instance.exports;
console.log(add(5, 3));       // 8
console.log(fibonacci(10));   // 55

πŸ’‘ Why "streaming"?

instantiateStreaming begins compiling the module as the bytes arrive, instead of waiting for the whole file to download first. For a multi-megabyte module that overlap is a real speedup. The one requirement: your server must send the file with the correct MIME type, application/wasm. Without it, the browser refuses to stream and you must fall back to the buffer approach below.

The fallback: fetch then instantiate

If you can't control the MIME type (or you already have the bytes in hand), compile from an ArrayBuffer:

async function loadModule(url, importObject) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch ${url}: ${response.status}`);
  }
  const bytes = await response.arrayBuffer();
  // Compile + instantiate from the raw bytes.
  return WebAssembly.instantiate(bytes, importObject);
}

const { instance } = await loadModule('math.wasm', {});
console.log(instance.exports.add(40, 2)); // 42
graph LR F["fetch('math.wasm')"] --> C["Compile to machine code"] C --> I["Instantiate
allocate memory, wire imports"] I --> E["instance.exports
ready to call"]

Calling Functions Both Ways

Interop flows in two directions. JavaScript calls into WASM through instance.exports, and WASM calls back out through the functions you supply in the import object.

JS calls WASM

Exported functions behave like ordinary JavaScript functions. You can store them in variables, pass them as callbacks, and wire them to event listeners:

const { multiply } = instance.exports;

document.getElementById('calculate').addEventListener('click', () => {
  const a = Number(document.getElementById('valueA').value);
  const b = Number(document.getElementById('valueB').value);
  // Runs compiled code, returns a plain JS number.
  document.getElementById('result').textContent = multiply(a, b);
});

WASM calls JS via imports

Since WebAssembly has no DOM or console of its own, you hand it JavaScript functions to call. Those live under a namespace (commonly env) in the import object:

const importObject = {
  env: {
    // A function the module can call to report progress.
    onProgress: (percent) => {
      console.log(`Working: ${percent}%`);
    },
    // You can also supply memory and other values here.
    memory: new WebAssembly.Memory({ initial: 10 })
  }
};

const { instance } = await WebAssembly.instantiateStreaming(
  fetch('worker.wasm'),
  importObject
);
instance.exports.run(); // internally calls back into onProgress

βœ… The mental model

Exports are the doors into the module. Imports are the doors out. You decide exactly which JavaScript capabilities the sandbox can reach by choosing what to put in the import object β€” nothing more leaks in.

The Interop Boundary

Every call between JavaScript and WebAssembly crosses a boundary, and what you can carry across it depends on the data's type. This single fact shapes almost every design decision you'll make.

The JavaScript to WebAssembly interop boundary: numbers pass cheaply while strings and objects must be marshalled through shared linear memory JavaScript DOM, events, fetch WebAssembly fast compute boundary numbers β€” cheap Shared linear memory strings and objects marshalled here
Numbers pass across the boundary almost for free; anything structured must be copied into shared linear memory β€” a step called marshalling.

The four native types

WebAssembly's core knows exactly four value types, all numeric:

WASM typeMeaningJavaScript equivalent
i3232-bit integernumber
i6464-bit integerBigInt
f3232-bit floatnumber
f6464-bit floatnumber

These pass directly and cheaply. Anything else β€” a string, an array, an object β€” has no native representation across the boundary. To move it, you serialize it into the shared linear memory and pass a pointer (just an i32 offset) instead.

⚠️ i64 needs BigInt

A 64-bit integer can't fit in a normal JavaScript number without losing precision, so i64 values surface as BigInt (42n). If your module exports i64 functions, be ready to handle BigInt on the JS side.

Working with Linear Memory

A module's linear memory is a resizable ArrayBuffer. JavaScript reaches into it by creating a typed-array view over memory.buffer. No copying is involved to view it β€” you're looking at the very same bytes the module sees.

// Create a memory: 2 pages now (each page is 64 KiB), up to 10.
const memory = new WebAssembly.Memory({ initial: 2, maximum: 10 });

// Different views interpret the same bytes differently:
const bytes = new Uint8Array(memory.buffer);   // one byte at a time
const ints  = new Int32Array(memory.buffer);   // four bytes at a time

bytes[0] = 255;
console.log(bytes[0]); // 255

// Grow by 2 more pages when you need room:
const oldPageCount = memory.grow(2); // returns the previous page count
console.log(`Grew from ${oldPageCount} pages`);

⚠️ Growing memory invalidates old views

When you call memory.grow(), the underlying ArrayBuffer may be replaced with a bigger one, which detaches your existing typed arrays. Any view you created earlier now points at nothing. Always recreate your views after growing:

memory.grow(2);
// The old `bytes` is now stale β€” make a fresh view:
const freshBytes = new Uint8Array(memory.buffer);

Allocating space inside the module

You don't scribble anywhere in memory β€” you ask the module for a free region. Most toolchains export a malloc/free pair (or generate them for you). The pattern is allocate β†’ write β†’ call β†’ read β†’ free:

const { malloc, free, sumBytes, memory } = instance.exports;

// 1. Ask WASM for 4 bytes of space; get back a pointer (offset).
const ptr = malloc(4);

// 2. Write into that region through a view.
const view = new Uint8Array(memory.buffer, ptr, 4);
view.set([10, 20, 30, 40]);

// 3. Call the compiled function, passing the pointer + length.
console.log(sumBytes(ptr, 4)); // 100

// 4. Always release what you allocated.
free(ptr);

Marshalling Strings

Strings are the classic "why is this hard?" case. JavaScript strings are UTF-16; C strings are UTF-8 bytes terminated by a zero. To hand a string to WASM you must encode it into memory; to read one back you must decode the bytes. The TextEncoder and TextDecoder APIs do the heavy lifting.

// --- JS -> WASM: write a string into linear memory ---
function passString(instance, str) {
  const { malloc, memory } = instance.exports;
  const encoder = new TextEncoder();
  const bytes = encoder.encode(str + '\0'); // null-terminated

  const ptr = malloc(bytes.length);
  new Uint8Array(memory.buffer).set(bytes, ptr);
  return ptr; // hand this pointer to a WASM function
}

// --- WASM -> JS: read a null-terminated string back out ---
function readString(instance, ptr) {
  const { memory } = instance.exports;
  const bytes = new Uint8Array(memory.buffer);
  let end = ptr;
  while (bytes[end] !== 0) end++;        // find the terminator
  return new TextDecoder().decode(bytes.subarray(ptr, end));
}

// Usage: length of a string, computed inside WASM
const ptr = passString(instance, 'Hello, WebAssembly!');
console.log(instance.exports.stringLength(ptr)); // 19
instance.exports.free(ptr);

πŸ’‘ Let the tooling do this for you

Writing marshalling by hand is educational but tedious. In real projects, wasm-bindgen (Rust) and Emscripten's cwrap/ccall generate this glue automatically, so you can pass a JavaScript string straight to a Rust function that expects a &str. Understanding the manual version means you'll know exactly what that generated code is doing.

Case Study: Image Filter

Image processing is the poster child for WebAssembly: millions of pixels, simple arithmetic per pixel, and a big buffer of numbers that lives in memory. Here's the full round trip β€” canvas pixels into WASM, filtered, and back onto the canvas.

graph TD UI["User clicks Apply Filter"] --> JS["JS controller"] JS -->|"copy pixels in"| MEM["Shared linear memory"] MEM --> WASM["WASM applies filter"] WASM -->|"writes result"| MEM MEM -->|"copy pixels out"| CAN["Canvas element"] CAN --> UI

The C filter (compiled to WASM)

// grayscale.c β€” compiled with Emscripten to grayscale.wasm
#include <emscripten.h>
#include <stdint.h>

// Convert an RGBA pixel buffer to grayscale, in place.
EMSCRIPTEN_KEEPALIVE
void applyGrayscale(uint8_t* data, int pixelCount) {
    for (int i = 0; i < pixelCount; i++) {
        uint8_t* p = data + i * 4;               // RGBA = 4 bytes
        uint8_t gray = (uint8_t)(0.299 * p[0] +  // luminance formula
                                 0.587 * p[1] +
                                 0.114 * p[2]);
        p[0] = gray;  // R
        p[1] = gray;  // G
        p[2] = gray;  // B
        // p[3] (alpha) is left unchanged
    }
}

The JavaScript driver

class ImageProcessor {
  async init(url) {
    const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {});
    this.exports = instance.exports;
    return this;
  }

  grayscale(canvas) {
    const ctx = canvas.getContext('2d');
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixels = imageData.data;            // Uint8ClampedArray, RGBA
    const { malloc, free, applyGrayscale, memory } = this.exports;

    // 1. Reserve space in WASM memory and copy the pixels in (ONE crossing).
    const ptr = malloc(pixels.length);
    new Uint8Array(memory.buffer).set(pixels, ptr);

    // 2. Do all the per-pixel work inside WASM in a single call.
    applyGrayscale(ptr, canvas.width * canvas.height);

    // 3. Copy the processed bytes back and repaint.
    const result = new Uint8Array(memory.buffer, ptr, pixels.length);
    pixels.set(result);
    ctx.putImageData(imageData, 0, 0);

    // 4. Release the buffer.
    free(ptr);
  }
}

const processor = await new ImageProcessor().init('grayscale.wasm');
processor.grayscale(document.getElementById('photo'));

βœ… The winning pattern

Notice we cross the boundary twice total (copy in, copy out) no matter how many pixels there are. Calling a WASM function once per pixel would cross millions of times and be far slower than plain JavaScript. Batch the work; cross rarely.

Integrating with a Framework

In a component-based app, the trick is to load the module once and handle the async lifecycle cleanly. Here's a compact React example that loads on mount and recomputes when its input changes:

import { useState, useEffect } from 'react';

function FibCalculator() {
  const [wasm, setWasm] = useState(null);
  const [n, setN] = useState(10);
  const [result, setResult] = useState(null);

  // Load the module a single time when the component mounts.
  useEffect(() => {
    let cancelled = false;
    WebAssembly.instantiateStreaming(fetch('/math.wasm'), {})
      .then(({ instance }) => {
        if (!cancelled) setWasm(instance);
      })
      .catch((err) => console.error('WASM load failed:', err));
    return () => { cancelled = true; };
  }, []);

  // Recompute whenever the module is ready or the input changes.
  useEffect(() => {
    if (wasm) setResult(wasm.exports.fibonacci(Number(n)));
  }, [wasm, n]);

  return (
    <div>
      <input
        type="number"
        value={n}
        onChange={(e) => setN(e.target.value)}
      />
      <p>Result: {result ?? 'Loading…'}</p>
    </div>
  );
}

πŸ’‘ Portable across frameworks

The pattern is identical in Vue (mounted + a watch) and Angular (a service that memoizes the module promise): load once, cache the instance, guard against calling before it's ready, and clean up on unmount. The WASM part doesn't change β€” only the framework's lifecycle hooks do.

Practice & Quiz

πŸ‹οΈ Exercise 1: Sum an array in one crossing

Goal: Given a module that exports malloc, free, memory, and sumInts(ptr, count), write a JavaScript function sumViaWasm(instance, arr) that copies a number array into WASM memory, calls sumInts once, and returns the total.

function sumViaWasm(instance, arr) {
  // TODO: allocate, copy in as Int32, call sumInts once, free, return
}
console.log(sumViaWasm(instance, [10, 20, 30])); // 60
πŸ’‘ Hint

Each i32 is 4 bytes, so malloc(arr.length * 4). Build an Int32Array view over memory.buffer starting at your pointer, .set(arr) into it, then call sumInts(ptr, arr.length). Free the pointer before returning.

βœ… Solution
function sumViaWasm(instance, arr) {
  const { malloc, free, sumInts, memory } = instance.exports;
  const ptr = malloc(arr.length * 4);            // 4 bytes per i32
  const view = new Int32Array(memory.buffer, ptr, arr.length);
  view.set(arr);                                 // copy in β€” one crossing
  const total = sumInts(ptr, arr.length);        // one WASM call
  free(ptr);
  return total;
}

The whole array crosses the boundary once via memory, and there is a single function call β€” exactly the batched pattern WASM rewards.

πŸ‹οΈ Exercise 2: Guard against stale views

Goal: This code sometimes throws "detached ArrayBuffer" after the module grows its memory. Explain why and fix it.

const view = new Uint8Array(instance.exports.memory.buffer);
instance.exports.loadBigAsset();   // may call memory.grow() internally
view[0] = 42;                      // πŸ’₯ sometimes throws
πŸ’‘ Hint

What happens to memory.buffer when the module grows its memory? Is the old view still pointing at a live buffer?

βœ… Solution

Growing memory can replace the underlying ArrayBuffer, detaching view. Create the view after any operation that might grow memory:

instance.exports.loadBigAsset(); // may grow memory
const view = new Uint8Array(instance.exports.memory.buffer); // fresh
view[0] = 42; // safe

🎯 Quick Quiz

Question 1: What does WebAssembly.instantiateStreaming improve over fetching an ArrayBuffer first?

Question 2: To pass a string to a WASM function, you must first:

Question 3: After calling memory.grow(), why must you recreate your typed-array views?

Best Practices & Pitfalls

βœ… Do

  • Prefer instantiateStreaming and serve .wasm as application/wasm
  • Load a module once and cache the instance; reuse it across calls
  • Batch work: copy bulk data into memory and process it in a single WASM call
  • Follow allocate β†’ write β†’ call β†’ read β†’ free and always release what you malloc
  • Recreate typed-array views after any memory.grow()
  • Let wasm-bindgen or Emscripten generate marshalling glue for real projects

❌ Don't

  • Call a WASM function once per element in a hot loop β€” the boundary overhead piles up
  • Hold onto a typed-array view across code that might grow memory
  • Forget to free β€” WASM linear memory has no garbage collector for your allocations
  • Assume i64 exports return a number β€” they return BigInt
  • Block first paint on a large module β€” load it lazily when the feature is actually used

⚠️ The overhead is real for small jobs

A single WASM call has fixed setup cost. For a one-off addition, JavaScript wins outright. WASM pays off when the work inside a call dwarfs the cost of making the call β€” which is exactly the theme of the next lesson.

Summary

πŸŽ‰ Key Takeaways

  • Load modules with WebAssembly.instantiateStreaming(fetch(...), importObject); fall back to instantiate(bytes, ...) when needed
  • Exports let JS call into WASM; the import object lets WASM call back into JS
  • Only numbers (i32, i64, f32, f64) cross cheaply; strings and objects need marshalling
  • Linear memory is a shared ArrayBuffer you view with typed arrays β€” recreate views after grow()
  • The reliable pattern is allocate β†’ write β†’ call β†’ read β†’ free, and batch to minimize boundary crossings
  • In frameworks, load once, cache the instance, and guard the async lifecycle

πŸ“š Additional Resources

πŸš€ What's Next?

You can now wire WASM into an app β€” but should you, and how do you make it fast? The next lesson, Performance Considerations, measures WASM against JavaScript, shows where it wins and loses, and covers optimization from source flags to SIMD and batching.

πŸŽ‰ You wired it up!

Loading, interop, memory, and a real image filter β€” that's the full integration toolkit. Next, we make it fly.