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.
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
);
}
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.
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.
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.