Instantiating WebAssembly Modules Inside Workers
Instantiating a WebAssembly module inside a dedicated worker is the recommended way to run native-speed computation off the main thread. This page covers WebAssembly in Workers at the implementation level, and sits inside the broader High-Performance Computation Patterns reference. You will find the minimal reproducible example first, then a line-by-line walkthrough, then the gotchas that trip up production deployments.
Minimal Reproducible Example
The snippet below is a complete, typed worker file. It handles both the instantiateStreaming path and the arrayBuffer fallback, supplies a typed imports object, calls an export, and transfers the result back to the main thread.
// wasm-worker.ts
// Receives: { type: 'INIT', wasmUrl: string }
// { type: 'RUN', id: string, input: ArrayBuffer, width: number, height: number }
// Sends: { type: 'READY' }
// { type: 'RESULT', id: string, output: ArrayBuffer }
// { type: 'ERROR', id: string, message: string }
interface WasmExports extends WebAssembly.Exports {
memory: WebAssembly.Memory;
process_pixels: (inputPtr: number, width: number, height: number) => number;
alloc: (byteLen: number) => number;
dealloc: (ptr: number, byteLen: number) => void;
}
let exports: WasmExports | null = null;
function buildImports(): WebAssembly.Imports {
return {
env: {
memory: new WebAssembly.Memory({ initial: 32 }), // 2MB starting heap
abort: (msg: number, file: number, line: number, col: number) => {
throw new Error(`WASM abort at ${file}:${line}:${col} — msg ptr ${msg}`);
},
},
};
}
async function instantiate(wasmUrl: string): Promise<void> {
const imports = buildImports();
let instance: WebAssembly.Instance;
try {
// Preferred path: streaming compilation + instantiation in one call
const result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports);
instance = result.instance;
} catch (streamErr) {
// Fallback: download fully, then compile from ArrayBuffer
console.warn('instantiateStreaming failed, falling back:', streamErr);
const response = await fetch(wasmUrl);
const buffer = await response.arrayBuffer();
const result = await WebAssembly.instantiate(buffer, imports);
instance = result.instance;
}
// Narrow to our expected export shape
const e = instance.exports;
if (!('process_pixels' in e) || !('memory' in e)) {
throw new Error('WASM module is missing required exports');
}
exports = e as WasmExports;
}
self.onmessage = async ({ data }) => {
try {
if (data.type === 'INIT') {
await instantiate(data.wasmUrl);
self.postMessage({ type: 'READY' });
return;
}
if (data.type === 'RUN' && exports) {
const { id, input, width, height } = data;
const byteLen = width * height * 4; // RGBA
// Write input into WASM linear memory
const heap = new Uint8Array(exports.memory.buffer);
const inputPtr = exports.alloc(byteLen);
heap.set(new Uint8Array(input), inputPtr);
// Run computation — returns pointer to output
const outputPtr = exports.process_pixels(inputPtr, width, height);
// Copy output to a fresh ArrayBuffer so we can transfer it
const output = new ArrayBuffer(byteLen);
// Re-read heap in case alloc/process_pixels triggered a grow
new Uint8Array(output).set(
new Uint8Array(exports.memory.buffer, outputPtr, byteLen)
);
exports.dealloc(inputPtr, byteLen);
self.postMessage({ type: 'RESULT', id, output }, [output]);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
self.postMessage({ type: 'ERROR', id: data.id ?? null, message });
}
};
And the minimal main-thread side:
// 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] // transfer: pixels is now detached in the main thread
);
}
Line-by-Line Walkthrough
buildImports() (lines 17–25): Every WASM module declares a set of host imports in its binary. The object returned here must match that declaration exactly — both the namespace key (env here) and the individual property names. A WebAssembly.Memory object is passed rather than letting the module create its own memory, giving JS-side control over the size. The abort function is called by AssemblyScript’s runtime on assertion failures.
WebAssembly.instantiateStreaming(fetch(url), imports) (lines 31–33): This combines network fetch, streaming compilation, and instantiation in one API call. The browser can start compiling WASM bytecode as bytes arrive, before the download completes — this is the fastest path for large modules. The return value is { module: WebAssembly.Module, instance: WebAssembly.Instance }.
Fallback block (lines 36–40): If instantiateStreaming throws — most likely due to a MIME-type mismatch — the code downloads the full binary with arrayBuffer() and calls WebAssembly.instantiate(buffer, imports). This works with any MIME type but loses the streaming-compile benefit.
Export narrowing (lines 44–49): TypeScript cannot infer the shape of instance.exports. The guard checks that the expected exports exist, then casts. Production code should enumerate all required export names.
exports.alloc(byteLen) (lines 63–64): Many WASM modules compiled from Rust (via wasm-bindgen) or C++ (via Emscripten) export an allocator. Calling alloc reserves space in the module’s managed heap and returns a pointer. Writing to arbitrary offsets without going through the allocator corrupts the module’s internal heap metadata.
Re-read exports.memory.buffer after compute (line 70): Any WASM function that internally calls memory.grow() replaces the underlying ArrayBuffer. The new Uint8Array(exports.memory.buffer, ...) on line 70 always reads from the current buffer, which is correct. A view captured before the call and reused after it would be detached.
Transfer list [output] (line 74): The freshly allocated ArrayBuffer is passed in the second argument to postMessage. This transfers ownership to the main thread with zero copy. After this line, output.byteLength === 0 in the worker.
Gotchas and Edge Cases
MIME type mismatch. This is the single most common instantiateStreaming failure in production. Ensure your server (Nginx, Vite, CDN) is configured to serve .wasm files as application/wasm. Example Nginx snippet:
types {
application/wasm wasm;
}
Vite handles this automatically in development. In production builds, check the hosting configuration explicitly.
CSP wasm-unsafe-eval requirement. If the page has a Content-Security-Policy that restricts script-src, WebAssembly compilation may be blocked. Chrome 97+ and Firefox 102+ support the wasm-unsafe-eval CSP keyword, which allows WASM compilation without allowing arbitrary eval. Add it to the script-src directive:
Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval';
Without this, WebAssembly.instantiateStreaming and WebAssembly.instantiate both throw CompileError: Wasm code generation disallowed by embedder.
Calling exports before READY. The worker is asynchronous — onmessage fires before instantiate() resolves if the main thread posts a RUN message immediately. The example above guards with if (data.type === 'RUN' && exports) and the error path returns a clean error message. A more robust design queues RUN messages received during initialisation and drains the queue once exports is set.
Import object key mismatch. If the WASM binary’s import namespace is wasi_snapshot_preview1 (Rust’s WASI target) rather than env, the imports object must use that key. Mismatches produce LinkError: WebAssembly.instantiate(): Import #0 module "wasi_snapshot_preview1" error: module is not an object or function. Read the module’s import section with WebAssembly.Module.imports(module) to inspect the declared names before instantiation if the toolchain is unfamiliar.
Performance Rule of Thumb
instantiateStreaming on a 500KB binary typically completes in 15–40ms on a modern laptop (M-series Mac: ~15ms; mid-range Android: ~40ms). The arrayBuffer fallback on the same binary adds the full download wait (cache miss) or an extra buffer copy (cache hit), pushing total time to 25–60ms. For modules used on every page load, compile once on the main thread and distribute the WebAssembly.Module struct to workers via postMessage as described in WebAssembly in Workers — this drops per-worker initialisation below 1ms.