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 .wasm artifact produced by your toolchain (wasm-pack build --target web, emcc, or hand-assembled wat2wasm).
  • [ ] A static server that sends Content-Type: application/wasm for .wasm responses (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.

Wasm instantiation lifecycle Bytes are fetched from the network, validated and compiled by the engine into a stateless Module, then instantiated against an import object to produce an Instance whose exports the host calls. 1 · Fetch stream .wasm bytes 2 · Validate + compile tiered JIT WebAssembly.Module stateless · cacheable 3 · Instantiate bind import object import object memory · funcs · globals 4 · Instance exports.run() each instantiation produces a fresh linear memory; the Module is reused

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-Type header is read. A 404 page served as text/html is 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 declared linear memory, copies in data and element segment contents, wires up the table, and finally runs the start function. A missing or mistyped import throws a LinkError; a trap in start throws a RuntimeError.

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 (fetchresponse.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

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 fetcharrayBuffer()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.

← Back to WebAssembly Core Concepts & Browser Runtime