Instantiating WebAssembly Modules Inside Workers

Getting a .wasm binary from a URL to a live WebAssembly.Instance inside a dedicated worker — the four calls that can do it, the imports object that makes them link, and the failure messages each one produces when it does not.

This is the implementation detail underneath WebAssembly in Workers, which sits in the High-Performance Computation Patterns reference. That parent guide covers the pipeline end to end — compiling once, distributing the compiled artefact, moving data through linear memory. This page stays on the instantiation call itself, because that is where the errors are loud, undocumented and easy to misread: a MIME type failure that reports as a TypeError, a LinkError that names an import you have never heard of, and a race where the first job arrives before the module is live.

Four Ways to Instantiate, and Which One a Worker Should Use

WebAssembly exposes four entry points, and the choice is decided by what the worker already holds — a URL, some bytes, or an already-compiled WebAssembly.Module.

Call Takes Compiles? Use inside a worker when
WebAssembly.instantiateStreaming(source, imports) Response or Promise<Response> Yes, overlapped with the download The worker fetches its own binary and the server sends application/wasm
WebAssembly.instantiate(bytes, imports) ArrayBuffer / TypedArray Yes, after the full download The MIME type is wrong, or the bytes came from the Cache API, IndexedDB or a bundler inline import
WebAssembly.instantiate(module, imports) WebAssembly.Module No — already compiled The main thread posted a compiled module to a pool of workers
new WebAssembly.Instance(module, imports) WebAssembly.Module No — synchronous You need the instance without an await, for example inside a synchronous message handler

The first two return a WebAssemblyInstantiatedSource{ module, instance } — while the module-taking overload of instantiate resolves to a bare Instance. Forgetting that difference produces undefined is not a function on the first export call, because result.exports was read off the wrapper object rather than off result.instance.

Workers get one privilege the main thread does not: synchronous compilation is unrestricted. new WebAssembly.Module(bytes) throws a RangeError on the main thread for anything larger than 4 KB — the platform refuses to block the UI thread on a compile — but inside a worker there is no such cap. That makes the synchronous constructors legitimate in a worker when the ergonomics justify them, though they still cost the full compile inline, so the asynchronous forms remain the default.

The fastest path in a pool is not on this page’s critical path at all: compile once with WebAssembly.compileStreaming and postMessage the resulting Module to every worker, then use the third row. A Module is structured-cloneable; an Instance is not. That distribution pattern is worked through in the parent guide, and it pairs naturally with the sizing rules in Worker Pool Management.

Pick the instantiation call from what the worker already holds A decision tree with one question and four outcomes. The question is: what does the worker already hold? If it holds a URL and the server sends application/wasm, call WebAssembly.instantiateStreaming with the source and the imports; it compiles while the bytes are still arriving and returns an object with module and instance fields. If it holds a URL but the MIME type is wrong, or it holds bytes recovered from a cache, call WebAssembly.instantiate with the bytes and the imports; it compiles only after the full download and also returns the module-and-instance wrapper. If it holds a compiled WebAssembly.Module posted from the main thread to a pool, call WebAssembly.instantiate with the module and the imports; nothing is compiled and it resolves to a bare Instance. If it holds a compiled Module and no await is available, for example inside a synchronous message handler, use new WebAssembly.Instance with the module and the imports; it is synchronous, legal in a worker at any size, and also yields a bare Instance. Only a worker may compile synchronously without a size cap. What does the worker already hold? a URL server sends application/wasm WebAssembly.instantiateStreaming(source, imports) compiles while the bytes are still arriving returns { module, instance } — read result.instance a URL, or bytes wrong MIME type, or bytes from a cache WebAssembly.instantiate(bytes, imports) compiles only after the full download returns { module, instance } — MIME-agnostic a compiled Module posted from the main thread to a pool WebAssembly.instantiate(module, imports) no compile — the module is already compiled resolves to a bare Instance, not the wrapper a compiled Module and no await available (sync message handler) new WebAssembly.Instance(module, imports) synchronous — legal in a worker at any size a bare Instance, returned without an await Only a worker may compile synchronously: new WebAssembly.Module(bytes) throws RangeError above 4 KB on the main thread. Reading .exports off the wrapper instead of off result.instance is what surfaces as 'undefined is not a function'.
The branch is decided by what the worker holds, not by trial and error — and the last column is the one that bites, because two of the four calls hand back a wrapper and two hand back the instance itself.

Minimal Reproducible Example

A complete worker that instantiates an image filter, then serves frames. It picks its instantiation path from the response headers rather than by catching a rejection, validates the export shape, and holds incoming work behind the initialisation promise.

/// <reference lib="webworker" />
// wasm-worker.ts
// In:  { type: 'INIT'; wasmUrl: string }
//      { type: 'RUN'; id: string; input: ArrayBuffer; width: number; height: number }
// Out: { type: 'READY' } | { type: 'RESULT'; id: string; output: ArrayBuffer }
//      { type: 'ERROR'; id: string | null; message: string }

declare const self: DedicatedWorkerGlobalScope;

interface FilterExports {
  memory: WebAssembly.Memory;
  alloc: (byteLen: number) => number;
  dealloc: (ptr: number, byteLen: number) => void;
  process_pixels: (inputPtr: number, width: number, height: number) => number;
}

const REQUIRED_EXPORTS = ['memory', 'alloc', 'dealloc', 'process_pixels'] as const;

let wasm: FilterExports | null = null;
let ready: Promise<void> | null = null;   // the init promise, awaited by every job

function buildImports(): WebAssembly.Imports {
  return {
    env: {
      // Supplied by the host so JS controls the ceiling. Only linked if the
      // binary *imports* memory — wasm-bindgen output exports it instead.
      memory: new WebAssembly.Memory({ initial: 32 }),   // 32 pages x 64KB = 2MB
      // AssemblyScript calls env.abort on a failed assertion.
      abort: (msgPtr: number, filePtr: number, line: number, column: number) => {
        throw new Error(`wasm abort at ${filePtr}:${line}:${column} (msg ptr ${msgPtr})`);
      },
      seed: () => Date.now(),
    },
  };
}

function assertExports(raw: WebAssembly.Exports): FilterExports {
  for (const name of REQUIRED_EXPORTS) {
    if (!(name in raw)) {
      throw new Error(`wasm module is missing required export "${name}"`);
    }
  }
  return raw as unknown as FilterExports;
}

async function instantiate(wasmUrl: string): Promise<void> {
  const imports = buildImports();
  const response = await fetch(wasmUrl);
  if (!response.ok) {
    throw new Error(`fetch ${wasmUrl} returned HTTP ${response.status}`);
  }

  const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim();
  let instance: WebAssembly.Instance;

  if (mime === 'application/wasm') {
    // Compilation starts on the first chunk, before the download finishes.
    ({ instance } = await WebAssembly.instantiateStreaming(response, imports));
  } else {
    // Buffered path: MIME-agnostic, one extra full-buffer step.
    console.warn(`[wasm] ${wasmUrl} served as "${mime || 'no content-type'}" — buffering`);
    const bytes = await response.arrayBuffer();
    ({ instance } = await WebAssembly.instantiate(bytes, imports));
  }

  wasm = assertExports(instance.exports);
}

self.onmessage = async ({ data }: MessageEvent) => {
  try {
    if (data.type === 'INIT') {
      ready ??= instantiate(data.wasmUrl);   // idempotent: a second INIT is a no-op
      await ready;
      self.postMessage({ type: 'READY' });
      return;
    }

    if (data.type === 'RUN') {
      if (!ready) throw new Error('RUN received before INIT');
      await ready;                            // queue, do not reject
      const e = wasm!;

      const { id, input, width, height } = data;
      const byteLen = width * height * 4;     // RGBA

      // alloc may grow linear memory, so build the view AFTER it returns.
      const inputPtr = e.alloc(byteLen);
      new Uint8Array(e.memory.buffer, inputPtr, byteLen).set(new Uint8Array(input));

      const outputPtr = e.process_pixels(inputPtr, width, height);

      // Copy out of linear memory into a buffer we are allowed to transfer.
      const output = new ArrayBuffer(byteLen);
      new Uint8Array(output).set(new Uint8Array(e.memory.buffer, outputPtr, byteLen));
      e.dealloc(inputPtr, byteLen);

      self.postMessage({ type: 'RESULT', id, output }, [output]);
    }
  } catch (err) {
    const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
    self.postMessage({ type: 'ERROR', id: data?.id ?? null, message });
  }
};

The main-thread half stays deliberately thin — it posts INIT once and then fires frames:

// main.ts
const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });

worker.postMessage({ type: 'INIT', wasmUrl: '/wasm/image-proc.wasm' });

worker.onmessage = ({ data }) => {
  if (data.type === 'READY') console.log('wasm worker ready');
  if (data.type === 'RESULT') displayResult(data.output);
  if (data.type === 'ERROR') console.error('worker error:', data.message);
};

function processFrame(pixels: ArrayBuffer, w: number, h: number): void {
  worker.postMessage(
    { type: 'RUN', id: crypto.randomUUID(), input: pixels, width: w, height: h },
    [pixels],   // transferred: pixels is detached on this side from here on
  );
}
A RUN posted during compilation queues instead of failing A sequence diagram with three lifelines: main.ts on the page's thread, wasm-worker.ts in a dedicated worker, and the server or CDN that serves the binary. The page posts INIT with the wasm URL. The worker fetches the URL, and the server answers 200 with Content-Type application/wasm. The worker then reads the content-type header and picks a path: application/wasm goes to instantiateStreaming with the response, anything else falls back to instantiate on the buffered array buffer. While that compile is still in flight the page posts RUN with id a; because the handler awaits the stored initialisation promise, that job parks rather than failing. Once linking finishes the worker posts READY, then a second RUN with id b arrives. The worker posts RESULT for a first and RESULT for b second: both handler invocations awaited the same promise, and promise reactions fire in FIFO order, so the jobs execute in the order they were posted. main.ts the page's thread wasm-worker.ts dedicated worker server / CDN serves the .wasm postMessage({ type: 'INIT', wasmUrl }) fetch(wasmUrl) 200 · Content-Type: application/wasm read content-type, then pick a path application/wasm → instantiateStreaming(response) anything else → instantiate(await res.arrayBuffer()) postMessage({ type: 'RUN', id: 'a' }) arrives mid-compile → parks on await ready postMessage({ type: 'READY' }) postMessage({ type: 'RUN', id: 'b' }) why 'a' still finishes first both parked on the same promise; its reactions run in FIFO order RESULT id 'a' — transfer [output] RESULT id 'b' — posted second, ran second Each message event gets its own handler invocation, and every one awaits the same init promise — so queued jobs run in post order.
The mustard bars are compile-and-link time; the olive bar is real work. Nothing here rejects: the only thing that changes when a job lands early is how long it waits.

Line-by-Line Walkthrough

declare const self: DedicatedWorkerGlobalScope. Without it, TypeScript resolves self to Window and rejects the two-argument postMessage with a transfer list, because Window.postMessage has a different signature. The /// <reference lib="webworker" /> triple-slash directive pulls in the worker DOM types for this file only, which is what you want when the worker lives in the same tsconfig project as the app. Bundler wiring for module workers is covered in Bundling Module Workers with Vite and webpack.

buildImports(). The object is two levels deep by definition: namespace, then import name. Every entry the binary declares in its import section must exist here and be the right kind — a function where the module expects a function, a WebAssembly.Memory where it expects a memory. Extra keys are harmless and ignored, which is why passing env.memory to a wasm-bindgen module (which exports memory instead of importing it) does nothing at all rather than failing loudly. Supplying the memory from JavaScript, when the module does import it, is what gives the host control over the initial size and the maximum ceiling.

response.headers.get('content-type'). This is the substantive change from the usual try/catch idiom. Catching a rejected instantiateStreaming and re-fetching costs a second network round trip on exactly the deployment that is already misconfigured, and it swallows genuine CompileErrors from a corrupt binary as if they were MIME problems. Branching on the header decides the path in microseconds and keeps a real compile failure loud. Splitting on ; matters because some servers send application/wasm; charset=utf-8, which fails an equality test against the bare type.

({ instance } = await …). Both branches destructure instance from the WebAssemblyInstantiatedSource wrapper. The parentheses are required — a bare { instance } = … at statement position parses as a block. The discarded module field is worth keeping when the worker will hand the compiled artefact on to other workers.

assertExports. instance.exports is typed as WebAssembly.Exports, an index signature of ExportValue, so TypeScript will let any property access through and every call site becomes a runtime gamble. Checking the names once and casting once means a module built with a renamed or tree-shaken export fails at startup with wasm module is missing required export "alloc" rather than at frame 900 with e.alloc is not a function.

ready ??= instantiate(...) and await ready in the RUN branch. Instantiation is asynchronous, so a RUN posted immediately after INIT reaches the handler while compilation is still in flight. Storing the promise and awaiting it turns that race into a queue: each message event runs its own handler invocation, each parks on the same promise, and promise reactions fire in FIFO order, so jobs still execute in the order they were posted. The ??= also makes a duplicated INIT — common under hot module replacement — idempotent instead of a second download.

The view built after alloc. new Uint8Array(e.memory.buffer, inputPtr, byteLen) is constructed after the allocator has run, because growing an unshared memory detaches the old ArrayBuffer and any view over it silently becomes zero-length. The same rule applies to the output copy, which reads e.memory.buffer again after process_pixels has returned. Where several workers must see the same bytes without copying, the memory has to be created with shared: true and the document served with Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp — see Sharing WebAssembly Linear Memory Across Workers.

self.postMessage({ … }, [output]). The freshly allocated ArrayBuffer goes in the transfer list, so ownership moves to the main thread with no copy and output.byteLength becomes 0 in the worker immediately afterwards. Linear memory itself can never be transferred this way — hence the copy — as explained under Transferable Objects & Zero-Copy.

Why the view has to be built after alloc() Four states of one job's linear memory, drawn as stacked strips. State one, before alloc: a low block of static data and stack, then free capacity across thirty-two pages of sixty-four kibibytes. State two, after alloc: the strip is longer because alloc called grow, an inPtr block of width times height times four bytes now sits above the static data, the old capacity ends where state one ended, and a grown region extends beyond it. Because grow replaced memory.buffer, every Uint8Array built before alloc now views a detached buffer and reports byteLength zero. State three, after process_pixels returned: the same inPtr block is unchanged and a new outPtr block of the same size holds the bytes the module wrote, with a little free space left. State four: the outPtr region is copied into a fresh ArrayBuffer created outside linear memory, which is what postMessage can transfer, and dealloc returns the inPtr block to the module's allocator. Linear memory itself can never be transferred, so one copy in and one copy out are unavoidable. 1 · before alloc() one job's linear memory static data + stack free capacity — 32 pages × 64 KiB nothing allocated yet the old memory.buffer ended here → 2 · after alloc() the buffer grew static data + stack inPtr — w × h × 4 input copied in with .set() free (old capacity) grown region alloc() called grow() every Uint8Array built before alloc() now views a detached buffer — byteLength 0 3 · after the call process_pixels() ran static data + stack inPtr (unchanged) the module read it outPtr — w × h × 4 the module wrote it free 4 · copy out then dealloc(inPtr) and transfer dealloc(inPtr, byteLen) returns the block to the module's allocator output = new ArrayBuffer(len) a copy that lives outside wasm postMessage(msg, [output]) — no copy Linear memory is never transferable — one copy in and one copy out are the price of the sandbox boundary. Build every view after the call that may grow memory, and re-read memory.buffer after each export returns.
States 1 and 2 are the whole bug: grow() hands back a different ArrayBuffer, so a view captured before alloc() keeps pointing at the old one and silently writes nothing.

Gotchas and Edge Cases

The MIME type is the single most common failure. instantiateStreaming requires application/wasm exactly; anything else rejects before compilation with a TypeError naming the received type. Vite and webpack dev servers get this right, and static hosting frequently does not. For Nginx:

types {
    application/wasm  wasm;
}

On Cloudflare Pages or Netlify, set it in the headers file; on S3 plus CloudFront it is per-object metadata, so a re-upload without --content-type application/wasm silently reverts it. The buffered path in the example keeps the site working meanwhile, and the console.warn is what tells you the deployment regressed.

CSP blocks compilation, not just eval. A Content-Security-Policy restricting script-src blocks WebAssembly compilation in the worker as well as on the page, and both instantiateStreaming and instantiate throw CompileError: Wasm code generation disallowed by embedder. The fix is the dedicated keyword, supported since Chrome 97 and Firefox 102, which permits WebAssembly without permitting arbitrary eval:

Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval';

A LinkError names a namespace you did not write. Rust’s wasm32-wasi target declares imports under wasi_snapshot_preview1, not env, and produces LinkError: Import #0 module "wasi_snapshot_preview1" error: module is not an object or function. Emscripten adds env.emscripten_notify_memory_growth when built with ALLOW_MEMORY_GROWTH. Rather than guessing, compile the module and print its import section:

const module = await WebAssembly.compileStreaming(fetch('/wasm/image-proc.wasm'));
console.table(WebAssembly.Module.imports(module));
// module                     name          kind
// 'env'                      'abort'       'function'
// 'env'                      'memory'      'memory'
console.table(WebAssembly.Module.exports(module));

WebAssembly.Module.imports() and .exports() are static, work on any compiled module, and take two lines in a scratch worker — considerably faster than reading toolchain documentation.

Instantiation errors are not worker.onerror errors. A rejected promise inside an async message handler surfaces as an unhandledrejection event in the worker, not on the main thread’s onerror, so an uncaught LinkError looks exactly like a worker that is simply slow to become ready. The try/catch in the example converts it into an explicit ERROR message; add self.addEventListener('unhandledrejection', …) as a backstop for anything thrown outside the handler.

Four ways instantiation fails, and what each one prints A four-panel matrix mapping each instantiation failure to its console text. Panel one, wrong MIME type: the host serves application/octet-stream or nothing, and instantiateStreaming rejects with a TypeError saying the response has an incorrect MIME type; the fix is to serve Content-Type application/wasm, or read the header first and branch to the buffered path. Panel two, a Content-Security-Policy without the wasm-unsafe-eval keyword: both instantiation calls throw a CompileError saying Wasm code generation is disallowed by the embedder; the fix is adding 'wasm-unsafe-eval' to script-src, which permits WebAssembly without permitting eval. Panel three, an import namespace mismatch: a Rust wasm32-wasi build is linked against an imports object keyed by env, and the console shows a LinkError naming module wasi_snapshot_preview1; the fix is to print WebAssembly.Module.imports for the compiled module and key the imports object by what it prints. Panel four, a job posted before the module is ready: nothing is printed at all, because the rejection surfaces as an unhandledrejection inside the worker and never reaches the page's onerror; the fix is to store the initialisation promise and await it at the top of every handler. The first two abort loudly; the last two look like a worker that is merely slow. 1 · Wrong MIME type TRIGGER The host serves application/octet-stream, text/plain or no Content-Type at all. CONSOLE TypeError: Failed to execute 'instantiateStreaming' on 'WebAssembly': Incorrect response MIME type. FIX Serve Content-Type: application/wasm — or read the header yourself and take the buffered path. 2 · CSP without wasm-unsafe-eval TRIGGER script-src is set and 'wasm-unsafe-eval' is absent. Both calls fail; the worker is not exempt. CONSOLE CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder FIX Add 'wasm-unsafe-eval' to script-src. It permits WebAssembly without permitting eval(). 3 · Import namespace mismatch TRIGGER A Rust wasm32-wasi build linked against an imports object keyed by env. CONSOLE LinkError: Import #0 module 'wasi_snapshot_preview1' error: module is not an object or function FIX Print WebAssembly.Module.imports(module) and key the imports object by what it lists. 4 · Job posted before READY TRIGGER A RUN reaches the handler while compilation is still in flight, and nothing awaits the promise. CONSOLE (silence) — the rejection surfaces as an unhandledrejection inside the worker FIX ready ??= instantiate(url), then await ready at the top of every handler; jobs queue in FIFO order. Only the top row aborts loudly. The bottom row looks exactly like a worker that is merely slow to become ready.
Match the console text, not the symptom: three of these four produce an error whose wording points somewhere other than the actual misconfiguration, and the fourth prints nothing on the main thread at all.

Performance Note

On a 2023-class laptop (Chrome 124, V8 12.4), a 500 KB release binary takes roughly 15–40 ms through instantiateStreaming end to end — about 15 ms on Apple silicon, nearer 40 ms on a mid-range Android device — because compilation overlaps the download and the baseline tier emits code as bytes arrive. The buffered path on the same binary lands at 25–60 ms: no overlap, plus a full-buffer copy before compilation starts. Linking itself is the cheap part, typically under 1 ms for a module with a handful of imports.

That gap is why the pooled pattern wins so decisively. Instantiating from a WebAssembly.Module that was compiled once on the main thread and posted across skips compilation entirely and completes in well under 1 ms per worker, so an eight-worker pool pays roughly 30 ms once instead of 30 ms eight times. Measure it in your own bundle rather than trusting the range:

const t0 = performance.now();
await instantiate('/wasm/image-proc.wasm');
console.log(`instantiate: ${(performance.now() - t0).toFixed(1)} ms`);

The rule of thumb: below about 100 KB the two network-facing paths are indistinguishable and the MIME branch is insurance rather than optimisation; above roughly 1 MB streaming is worth a deployment fix on its own. Above about four workers, stop instantiating per worker and distribute a compiled module instead. Once the instance is live, per-call throughput is a separate question entirely — that is where Using SIMD in Worker Threads applies.

Time to the first export call, 500 KB release binary A horizontal bar chart in three groups, measured in milliseconds on a 2023-class laptop running Chrome 124 with V8 12.4. For one worker fetching its own binary, instantiateStreaming on the response takes about 20 milliseconds, with a range of 15 to 40 across devices, while instantiate on an awaited arrayBuffer takes about 40 milliseconds, range 25 to 60, because there is no overlap between download and compile. For one worker handed an already-compiled Module, instantiate with that module and the imports takes about 0.6 milliseconds — a bar barely wider than the axis, because there is nothing left to compile. For an eight-worker pool, instantiating per worker eight times costs about 160 milliseconds in total, whereas compiling once with compileStreaming and posting the Module to all eight costs about 25 milliseconds in total. Above roughly four workers, compile once and post the module: the pool pays the compile once rather than once per worker. one worker fetching its own binary instantiateStreaming(response) ≈20 ms · range 15–40 instantiate(await res.arrayBuffer()) ≈40 ms · range 25–60 one worker handed a compiled Module instantiate(module, imports) ≈0.6 ms · nothing left to compile eight-worker pool — total to all-ready instantiateStreaming × 8 workers ≈160 ms compile once, post the Module ≈25 ms · one compile, eight links 0 20 40 60 80 100 120 140 160 milliseconds to the first export call — 500 KB release binary, Chrome 124 / V8 12.4 Above about four workers, stop instantiating per worker: the pool pays one compile in total instead of one each.
The two network-facing paths differ by a single deployment header. The gap that actually decides an architecture is the third bar against the fourth — compilation, paid once or paid per worker.

Frequently Asked Questions

Why does WebAssembly.instantiateStreaming fail inside a worker?
Almost always a MIME type. The response must carry Content-Type: application/wasm; if it arrives as text/plain, application/octet-stream or nothing at all, instantiateStreaming rejects with a TypeError before compiling a single byte, and the message names the MIME type rather than the real fix. The other two causes are a Content-Security-Policy that lacks wasm-unsafe-eval (which surfaces as CompileError: Wasm code generation disallowed by embedder) and a 404 whose HTML error page is fed to the compiler. Reading response.headers.get('content-type') yourself and branching to WebAssembly.instantiate(await response.arrayBuffer(), imports) sidesteps the first case without paying for a second download.
How do I pass environment functions such as memory or a log callback to the module?
Supply an imports object as the second argument to instantiateStreaming or instantiate. It is keyed by import namespace first and import name second — { env: { abort, seed, memory } } for AssemblyScript, { wasi_snapshot_preview1: { … } } for a Rust WASI build — and every function, WebAssembly.Memory, WebAssembly.Table and global the binary declares must be present and of the right kind. Extra keys are ignored; missing or mismatched ones throw a LinkError at instantiation time that names the offending module and import. Run WebAssembly.Module.imports(module) against an unfamiliar binary to print the exact list before you guess.

See also