Wasm Instantiation Lifecycle
A .wasm file on disk is inert. Before any exported function runs, the browser must walk it through
three distinct phases — fetch the bytes over the network, compile them to machine code while
validating every opcode, and instantiate the compiled module by binding its imports and allocating
its linear memory. Treating these as one opaque await hides the failure modes that bite in
production: a LinkError because the import object was missing a function, a silently slow path
because the server returned the wrong MIME type, or a trap thrown from a start function before your
code ever got control. This guide separates the phases so each becomes an independently measurable,
independently debuggable checkpoint.
The payoff for understanding the split is concrete. Fetch is networking you already know how to
optimize — caching, compression, CDNs. Compile is CPU work you reduce by shrinking the binary and reuse
by caching the compiled WebAssembly.Module. Instantiate is cheap unless you reserve a large memory or
run heavy start logic. When a cold start feels slow, you cannot fix it without knowing which phase owns
the time, and the only way to know is to stop treating the lifecycle as a single black box.
Prerequisites
- [ ] A modern engine — Chrome/Edge 119+, Firefox 120+, or Safari 17+ — all ship
WebAssembly.instantiateStreaming. - [ ] A
.wasmartifact produced by your toolchain (wasm-pack build --target web,emcc, or hand-assembledwat2wasm). - [ ] A static server that sends
Content-Type: application/wasmfor.wasmresponses (streaming refuses any other type). - [ ] Node 18+ if you want to validate or compile modules outside the browser with the same API surface.
- [ ] DevTools open on the Network and Performance panels to observe the compile/instantiate split in real time.
The three phases, end to end
The lifecycle is a strict pipeline. Bytes arrive from the network; the engine compiles and validates them
into a WebAssembly.Module (a stateless, shareable artifact); then instantiation pairs that module with an
import object to produce a WebAssembly.Instance whose exports you can finally call. Each arrow below
is a place where work happens — and where it can fail.
A key property: compilation produces a stateless WebAssembly.Module. The same module can be instantiated
many times, each instance getting its own fresh linear memory, and a module can even be structured-cloned
to a Web Worker via postMessage without recompiling. Instantiation is where state — memory, table slots,
mutable globals — comes into existence and where the import object is bound.
It helps to name what each phase actually costs and what it can throw, because the failures surface at different points in your code:
- Fetch is ordinary networking. It can fail with a network error, a 404, or a redirect, and — for
streaming — it is where the
Content-Typeheader is read. A 404 page served astext/htmlis the most common reason streaming “mysteriously” refuses to compile. - Compile validates every opcode against the type rules and lowers the module to machine code through a
tiered JIT (a fast baseline tier first, an optimizing tier later under load). Malformed or unsupported
bytes throw a
CompileError; this is also where a binary built for a CPU feature the engine lacks (SIMD, threads) is rejected. Compilation is CPU-bound and, on the streaming path, overlaps the download. - Instantiate resolves imports against the
import object, allocates the declaredlinear memory, copies indataandelement segmentcontents, wires up thetable, and finally runs thestartfunction. A missing or mistyped import throws aLinkError; atrapinstartthrows aRuntimeError.
Because these are distinct phases, you can measure them distinctly. Wrapping compileStreaming and
new WebAssembly.Instance in separate performance.mark calls tells you whether a slow cold start is
compile-bound (shrink the binary, cache the Module) or instantiate-bound (usually a large initial memory
reservation or an expensive start function), and the remedies are completely different.
Numbered workflow
1. Produce and optimize the binary
Strip debug sections and shrink the payload before it ever hits the network, because every byte is a byte the engine must stream and validate.
# Rust → wasm, targeting the browser
wasm-pack build --target web --release
# Strip and size-optimize the raw module
wasm-opt pkg/app_bg.wasm -Oz -o pkg/app_bg.wasm
2. Serve it with the correct MIME type
Streaming compilation reads the Content-Type response header and refuses to proceed unless it is exactly
application/wasm. Configure your dev and production servers accordingly; getting this right locally is the
subject of the local development server configurations guide.
# Quick check that the server advertises the right type
curl -sI http://localhost:8080/app.wasm | grep -i content-type
# expect: content-type: application/wasm
3. Fetch and compile in one streamed pass
WebAssembly.instantiateStreaming takes the Response (or a promise of one) directly and compiles bytes
as they arrive, overlapping download and compilation. It resolves to a { module, instance } record.
const importObject = { env: { /* bound in step 4 */ } };
const { module, instance } = await WebAssembly.instantiateStreaming(
fetch("/app.wasm"),
importObject,
);
4. Bind the import object
Instantiation resolves every declared import by name. A module that imports env.log and env.memory
will throw a LinkError unless your import object supplies both, with matching shapes.
const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 }); // 1 MiB → 16 MiB
const importObject = {
env: {
memory,
log: (ptr, len) =>
console.log(new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len))),
now: () => performance.now(),
},
};
5. Call the exports
After instantiation, instance.exports holds the module’s exported functions, memories, tables, and
globals as live JavaScript values. Exported functions accept and return only numbers — everything else
travels through linear memory, the topic of the JS/Wasm interop & memory management section.
const sum = instance.exports.add(40, 2); // 42
const mem = new Uint8Array(instance.exports.memory.buffer);
A complete JS binding example
This pattern streams, compiles, and instantiates with a shared import object that supplies a memory
and a logging callback. The module writes a UTF-8 string into that memory and calls back into the host to
print it — exercising both directions of the boundary in one round trip.
async function bootModule(url) {
const memory = new WebAssembly.Memory({ initial: 2, maximum: 64 });
const importObject = {
env: {
memory,
// The module calls env.log(ptr, len) to surface a string from linear memory.
log(ptr, len) {
const bytes = new Uint8Array(memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
},
},
};
const { instance } = await WebAssembly.instantiateStreaming(fetch(url), importObject);
// exports.greet writes "hello" at some offset and calls back into env.log.
instance.exports.greet();
// Re-derive views from memory.buffer *after* any call that might have grown memory.
return { instance, memory };
}
Because the same WebAssembly.Memory object is referenced both by the host (memory.buffer) and by the
module (its imported memory), no copy occurs — the host and guest read and write the same ArrayBuffer. If
the module ever calls memory.grow, the old buffer detaches and any pre-built view goes zero-length, so
views must be re-created from memory.buffer after a grow.
There are two ways to decide who owns the memory. If the host creates the WebAssembly.Memory and passes
it in through the import object (as above), the host can size it, share it across instances, and back it
with a SharedArrayBuffer for threading. Alternatively, the module can declare and export its own memory,
in which case you read instance.exports.memory after instantiation instead of constructing it yourself.
A module compiled by wasm-pack or Emscripten typically exports its memory; a hand-written wat module
often imports it. Knowing which convention your toolchain uses tells you whether to put memory in the
import object or to fish it out of exports — getting this backwards is a frequent source of a confusing
LinkError complaining about an unexpected or missing memory import.
The shape of every entry in the import object must match the module’s declaration exactly. A function
import must be a callable with a compatible arity; a memory import must be a WebAssembly.Memory whose
initial/maximum constraints satisfy the module’s; a global import must be a WebAssembly.Global of the
right type and mutability. The engine validates these at instantiation time and rejects mismatches before a
single instruction runs, which is why instantiation is the right place to catch integration bugs.
Optimization & tradeoffs
Streaming vs ArrayBuffer. instantiateStreaming overlaps download and compilation, so the engine is
already emitting machine code before the last byte lands — the lowest cold-start latency available. The
non-streaming path (fetch → response.arrayBuffer() → WebAssembly.instantiate(bytes)) waits for the
full download, then compiles, costing you the compile time as a serial step after the network. For a 2 MB
module on a typical connection, that serial compile step is the difference between an ~18 ms and an ~32 ms
time-to-first-call. The full comparison, including a robust fallback, lives in the
streaming vs ArrayBuffer instantiation guide.
Split compile and instantiate. When you instantiate the same module repeatedly, compile once with
WebAssembly.compileStreaming, persist the resulting WebAssembly.Module, and call
new WebAssembly.Instance(module, importObject) per use. The Module is the expensive, cacheable artifact;
the Instance is cheap to mint.
// Cache the compiled module across instantiations / sessions.
const module = await WebAssembly.compileStreaming(fetch("/app.wasm"));
const a = new WebAssembly.Instance(module, importObject); // fresh memory
const b = new WebAssembly.Instance(module, importObject); // fresh memory, no recompile
Cache compiled modules. A WebAssembly.Module survives a structured clone, so it can be stored in
IndexedDB and restored on the next visit, skipping recompilation entirely. Warm starts drop from tens of
milliseconds of compile time to sub-millisecond instance creation.
import { openDB } from "idb";
async function getModule(url) {
const db = await openDB("wasm-cache", 1, {
upgrade: (d) => d.createObjectStore("modules"),
});
let mod = await db.get("modules", url);
if (!mod) {
mod = await WebAssembly.compileStreaming(fetch(url));
await db.put("modules", mod, url); // structured-clone serializes the compiled module
}
return mod;
}
Clone to workers. Compile on the main thread (or a worker), then postMessage the WebAssembly.Module
to other workers — each instantiates against its own memory without paying the compile cost again. This is
the foundation of a worker pool: one compile, many instances.
// main thread: compile once, hand the module to every worker
const module = await WebAssembly.compileStreaming(fetch("/app.wasm"));
for (const worker of pool) {
worker.postMessage({ module }); // structured clone, no recompile in the worker
}
// inside the worker:
self.onmessage = ({ data }) => {
const instance = new WebAssembly.Instance(data.module, importObject);
// each worker gets its own linear memory and independent state
};
The numbers behind the table matter: compilation of a 2 MB module is tens of milliseconds, while minting a
fresh Instance from an already-compiled Module is well under a millisecond. So the moment you have more
than one instantiation — a worker pool, a hot-reload loop, or a return visit — the cost model flips
decisively toward “compile once, instantiate many,” and the only question left is where you persist the
compiled Module: in process memory (worker pool), in IndexedDB (across sessions), or both.
| Decision | Choose this | When |
|---|---|---|
instantiateStreaming |
Lowest cold start | First load, single instance, server sends application/wasm |
compileStreaming + cache |
Reuse across sessions | Repeat visits, multiple instances, worker pools |
instantiate(arrayBuffer) |
Compatibility / bundlers | MIME type uncontrollable, bytes already in memory, tiny modules |
Gotchas & failure modes
LinkError: import does not provide a function. Instantiation throws synchronously when the
import object omits an import the module declares, or supplies the wrong kind. The message names the
missing entry:
LinkError: WebAssembly.instantiate(): Import #0 module="env" function="log" error:
function import requires a callable
The fix is to make the import object’s shape match the module’s import section exactly — inspect it with
WebAssembly.Module.imports(module) and supply every entry.
Wrong MIME type silently breaks streaming. If the server returns Content-Type: application/octet-stream
(or text/html for a 404 page), instantiateStreaming rejects with a TypeError rather than compiling.
Always pair streaming with a fallback to the ArrayBuffer path, and verify the header with curl -I.
A trap in the start function kills instantiation. If the module declares a start function, it runs
during instantiation. A division by zero, an out-of-bounds load, or an explicit unreachable there
throws a RuntimeError before instance.exports is ever returned — your await rejects and no instance
exists. Wrap instantiation in try/catch and treat a start-time trap as a fatal init error.
Detached views after memory.grow. Any typed-array view built over memory.buffer becomes
zero-length once the module grows memory, because growth can reallocate the backing ArrayBuffer. Re-create
views after any call that might grow memory.
RangeError from an over-large memory reservation. Declaring a huge maximum (or initial) on a
WebAssembly.Memory can exceed the engine’s address-space limit and throw a RangeError at construction
or instantiation time. On 32-bit memories the hard ceiling is 4 GiB, but engines often cap practical
reservations well below that. Size initial to your real working set and let the module grow, rather than
reserving the theoretical maximum up front.
CompileError from an unsupported feature. A module built with SIMD, threads, or another post-MVP
feature will fail to compile on an engine that does not support it — the error arrives at the compile phase,
not at call time. Detect the capability with a tiny probe module passed to WebAssembly.validate and serve
a baseline binary as a fallback.
Verification
Before instantiating, you can confirm a byte buffer is a structurally valid module without compiling it
fully, using WebAssembly.validate. It returns a boolean and never throws.
const bytes = new Uint8Array(await (await fetch("/app.wasm")).arrayBuffer());
console.log(WebAssembly.validate(bytes)); // true if the module passes validation
After instantiating, inspect what actually came across the boundary — the exports object lists every exported function, memory, table, and global by name.
const { instance, module } = await WebAssembly.instantiateStreaming(fetch("/app.wasm"), importObject);
console.log(Object.keys(instance.exports)); // ["memory", "add", "greet", ...]
console.log(WebAssembly.Module.imports(module)); // what the module *required*
console.log(WebAssembly.Module.exports(module)); // what it advertises
console.log(instance.exports.memory.buffer.byteLength / 65536, "pages"); // 64 KiB each
From the command line, wasm-objdump -x app.wasm dumps the import and export sections so you can build the
import object to match before you ever load the module in a browser. The Import[...] block lists exactly
the names your import object must provide, and the Export[...] block tells you which symbols will appear
on instance.exports. Reconciling those two lists against your JavaScript glue is the fastest way to
eliminate LinkError surprises — every entry the module imports must have a corresponding key in your
import object, and every function you call from JavaScript must appear in the export list. When the lists
disagree with what your code assumes, fix the binding before debugging anything downstream.
In this guide
- Streaming Instantiation vs ArrayBuffer Instantiation — when each path wins, the MIME-type requirement, and a fallback that never recompiles needlessly.
Frequently Asked Questions
What is the difference between a WebAssembly.Module and a WebAssembly.Instance?
A Module is the compiled, stateless result of validating and compiling the bytes — it owns no memory and
can be shared across threads or cached. An Instance is a Module bound to a specific import object,
with its own linear memory, table slots, and mutable globals. One module yields many independent
instances.
Why does instantiateStreaming reject when my server clearly returns the file?
Streaming compilation requires the response’s Content-Type to be exactly application/wasm. A 200 response
with application/octet-stream, or a 404 served as text/html, both cause a TypeError. Fix the server
header, or fall back to fetch → arrayBuffer() → instantiate.
Do I always need an import object?
Only if the module declares imports. A self-contained module with no imports can be instantiated with no
second argument, or with {}. Inspect WebAssembly.Module.imports(module) to see what is required.
Can I instantiate the same compiled module twice?
Yes — that is the point of separating compile from instantiate. Compile once with compileStreaming, then
call new WebAssembly.Instance(module, importObject) as many times as you need; each instance gets a fresh
memory and independent state, with no recompilation cost.
Where does the start function run in the lifecycle?
During instantiation, after imports are resolved and memory is initialized but before instance.exports
is handed back. A trap in start aborts instantiation and rejects the promise, so treat it as part of
your init error handling.
Related
- Streaming vs ArrayBuffer instantiation — the two load paths compared, with a production fallback.
- JS/Wasm interop & memory management — how the
import objectandlinear memorycarry data once an instance exists. - Local development server configurations — serving
.wasmwith theapplication/wasmMIME type that streaming demands. - Wasm binary format deep dive — how the module’s sections drive validation and runtime allocation.
- Stack vs heap execution model — what executes once exports are called.