π 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
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 four native types
WebAssembly's core knows exactly four value types, all numeric:
| WASM type | Meaning | JavaScript equivalent |
|---|---|---|
i32 | 32-bit integer | number |
i64 | 64-bit integer | BigInt |
f32 | 32-bit float | number |
f64 | 64-bit float | number |
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.
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
instantiateStreamingand serve.wasmasapplication/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-bindgenor 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
i64exports return anumberβ they returnBigInt - 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 toinstantiate(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
ArrayBufferyou view with typed arrays β recreate views aftergrow() - 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
- MDN β Loading and running WebAssembly code
- MDN β Using the WebAssembly JavaScript API
- The
wasm-bindgenGuide - WebAssembly.org β Developer's Guide
π 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.