WebAssembly in Workers

WebAssembly and Web Workers are usually introduced as the same idea — “make it fast” — but they fix two unrelated problems, and shipping one without the other is the most common way to end up disappointed. This guide is a specialisation of High-Performance Computation Patterns for compiled code: how to compile a .wasm binary once, hand the compiled module to a pool of workers, move bytes in and out of linear memory without accidental copies, grow that memory safely, share it across threads, and prove afterwards that the work really left the main thread.

Two Different Bottlenecks, Two Different Fixes

Consider a browser-side audio editor that decodes a 4MB FLAC file when the user drops it on the page. Written in JavaScript on the main thread, the decode takes on the order of 300ms on a mid-range laptop. During those 300ms the page cannot paint, the drop indicator stays frozen mid-animation, and the roughly eighteen 16.7ms frame slots that pass are all missed. The Chrome Performance panel shows it as a single long task with a red corner.

There are two independent things wrong with that, and they need different remedies:

  • Throughput. A bit-level codec inner loop is exactly the kind of code a JavaScript engine struggles to keep in its fastest tier: dense integer arithmetic, unpredictable branches, and hot bounds-checked array access. Compiled WebAssembly typically lands the same decode in roughly a third of the time, because it is ahead-of-time-typed, has no deoptimisation cliffs, and produces no garbage for the collector to reclaim mid-decode.
  • Occupancy. Even a 90ms decode still blocks five frames. Only moving the work to another thread fixes that, and moving it is what a dedicated worker is for.

Put differently: WebAssembly makes the task shorter, a worker makes it invisible. Run compiled code on the main thread and you still drop frames — merely fewer of them. Run JavaScript in a worker and the UI stays smooth, but a 30fps camera pipeline that needs a frame every 33ms will still fall behind. Workloads that want both are recognisable by shape:

  • Codecs and containers — FLAC, Opus, H.264 demuxing, JPEG XL — where the reference implementation already exists in C and porting it is cheaper than rewriting it.
  • Numerical kernels — FFTs, matrix factorisation, physics integration, ray marching — tight loops over Float32Array-shaped data with no allocation.
  • Per-pixel and per-sample transforms — convolution, colour-space conversion, resampling — which gain again from 128-bit SIMD, covered in Using SIMD in Worker Threads.
  • Cryptographic primitives — argon2, blake3, ChaCha20 — where constant-time behaviour and predictable instruction counts matter as much as speed.
Throughput and occupancy are independent axes A two-by-two matrix for a 4MB FLAC decode. Columns are the language: JavaScript, which is tiered and garbage-collected, and WebAssembly, which is ahead-of-time typed with no deoptimisation and no garbage collection. Rows are the thread: main thread, which shares the frame budget, and worker, which has its own event loop. JavaScript on the main thread takes 300 milliseconds and drops about 18 frames — both problems present. WebAssembly on the main thread takes 90 milliseconds and still drops about 5 frames — throughput fixed, occupancy not. JavaScript on a worker drops no frames but still takes 300 milliseconds, missing a 33 millisecond camera deadline — occupancy fixed, throughput not. WebAssembly on a worker takes 90 milliseconds off-thread and drops no frames, and is the only quadrant that satisfies both deadlines. → throughput ↓ occupancy JavaScript tiered JIT, deopt cliffs, GC pauses WebAssembly AOT-typed, no deopt, no GC Main thread shares the frame budget 300 ms decode · 18 frames dropped one long task: layout, paint and input are all stalled until it finishes Both problems present 90 ms decode · 5 frames dropped three times faster, and still blocking every frame inside those 90 ms Throughput fixed, occupancy not Worker own event loop 300 ms decode · 0 frames dropped the UI stays smooth, but a 33 ms camera frame lands before the result Occupancy fixed, throughput not 90 ms decode · 0 frames dropped the compute sits on a worker track; the main thread never waits for it Both fixed — ship this quadrant Only the outlined quadrant clears a 16.7 ms frame budget and a 33 ms capture deadline at the same time.
Compiling moves you left to right; moving off the main thread moves you top to bottom. Disappointing rewrites are almost always ones that travelled along only one axis.

There is a third, quieter cost that only appears once the worker pool grows. Compiling a WebAssembly binary is the expensive part of the lifecycle, and instantiating the same 2MB module independently in eight workers means paying for eight compilations. The whole first half of this guide exists to make sure you pay that price exactly once.

Prerequisites

Before wiring any of this up, confirm the following:

  • The server sends Content-Type: application/wasm. compileStreaming and instantiateStreaming validate the MIME type before they read a byte, and reject on anything else — including application/octet-stream, which is what most static hosts default to for unknown extensions.
  • A toolchain that exports what you need. Whatever emits your binary — Emscripten, wasm-pack/wasm-bindgen, clang --target=wasm32, TinyGo, AssemblyScript — must export the memory (or accept an imported one) plus allocator functions if you intend to pass buffers larger than a scratch region. For Emscripten that means -sEXPORTED_FUNCTIONS=_malloc,_free and usually -sIMPORTED_MEMORY.
  • A worker script URL that the bundler understands. new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' }) is the portable spelling; Bundling Module Workers with Vite and webpack covers the build configuration, including how each bundler emits the .wasm asset alongside the worker chunk.
  • Cross-origin isolation, but only if you need shared memory. Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: require-corp are required for shared: true memory and for the threads proposal. The single-worker path described in Steps 1–4 needs no special headers at all.
  • A baseline measurement. Record the current cost with performance.now() before you change anything, on the hardware you actually care about. Without it you cannot tell a real win from a rounding error.
  • Debug symbols if you plan to profile. Keep the WebAssembly name section (-g1 in Emscripten, the default in wasm-pack --dev) so DevTools shows function names rather than wasm-function[218].

Step-by-Step Implementation

Step 1 — Compile once, distribute the compiled module

Compilation turns the binary into machine code, and it dominates startup: budget roughly 10–50ms for a 500KB module on a mid-range laptop. Engines soften this with tiering — V8 compiles first with the fast baseline compiler Liftoff so execution can start early, then re-compiles hot functions with the optimising TurboFan tier in the background — and with streaming, which overlaps compilation with the network transfer so a module can be ready almost as soon as the last byte lands. Chrome additionally writes compiled code to its disk cache for streamed modules over roughly 128KB, so repeat visits skip most of the work.

None of that helps if each worker fetches and compiles the binary for itself. Compile once, then post the resulting WebAssembly.Module to every worker that needs it: modules are serializable by the structured clone algorithm, so the receiving worker gets the compiled artefact rather than a fresh download.

// main.ts — one fetch, one compile, N workers
async function compileModule(url: string): Promise<WebAssembly.Module> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
  try {
    // Fast path: compiles while the body is still streaming in.
    return await WebAssembly.compileStreaming(response);
  } catch (err) {
    // Thrown when the host serves the wrong MIME type. Buffer and retry once
    // so a misconfigured CDN degrades in speed rather than breaking the feature.
    console.warn('Streaming compile failed, falling back to arrayBuffer():', err);
    const bytes = await (await fetch(url)).arrayBuffer();
    return WebAssembly.compile(bytes);
  }
}

const compiled = await compileModule('/wasm/compute.wasm');

const pool = Array.from({ length: 4 }, () => {
  const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
  // Structured clone, NOT a transfer: `compiled` stays usable here afterwards.
  worker.postMessage({ type: 'INIT', module: compiled });
  return worker;
});
Trade-off: compile cost is paid per module, instantiate cost per worker

Compilation scales with binary size (tens of milliseconds for a few hundred kilobytes); instantiation only allocates memory and resolves imports, and is usually well under a millisecond. Cloning a compiled module to eight workers therefore costs eight cheap instantiations instead of eight expensive compiles. The clone is not free — the engine copies its record of the compiled code — but it is a fraction of re-compiling, and it never re-reads the network.

Sending a module rather than a URL also removes an entire class of race: every worker in the pool is guaranteed to be running the same build, even if a deploy swaps the file mid-session. If you would rather keep the fetch inside the worker — simpler wiring, one less message type — Instantiating WebAssembly Modules Inside Workers walks through that variant and its startup profile.

Step 2 — Instantiate inside the worker

Instantiation binds a compiled module to a set of imports and produces an Instance with live exports. Unlike the module, an instance is not serializable and never crosses a thread boundary — each worker builds its own. Keep the instance in worker-scope state and treat INIT as a one-shot handshake, so subsequent job messages have no setup cost at all.

// wasm-worker.ts
interface WasmExports {
  memory: WebAssembly.Memory;
  alloc: (byteLen: number) => number;
  dealloc: (ptr: number, byteLen: number) => void;
  process_frame: (inPtr: number, outPtr: number, width: number, height: number) => void;
}

let exports: WasmExports | null = null;

type Incoming =
  | { type: 'INIT'; module: WebAssembly.Module }
  | { type: 'RUN'; input: ArrayBuffer; width: number; height: number };

self.onmessage = async ({ data }: MessageEvent<Incoming>) => {
  if (data.type === 'INIT') {
    const t0 = performance.now();

    const imports: WebAssembly.Imports = {
      env: {
        // Every import the binary declares must appear here, with the right kind.
        log_i32: (value: number) => console.log('[wasm]', value),
        now_ms: () => performance.now(),
        abort: (code: number) => { throw new Error(`wasm abort(${code})`); },
      },
    };

    const instance = await WebAssembly.instantiate(data.module, imports);
    exports = instance.exports as unknown as WasmExports;

    self.postMessage({ type: 'READY', instantiateMs: performance.now() - t0 });
    return;
  }

  if (data.type === 'RUN') {
    if (!exports) throw new Error('RUN received before INIT completed');
    runJob(exports, data.input, data.width, data.height);
  }
};
Two shapes of instantiate()

Called with a Module, WebAssembly.instantiate() resolves to an Instance. Called with an ArrayBuffer or Response, it resolves to { module, instance } instead. Destructuring the wrong one is the single most common cause of undefined exports at startup — if instance.exports is undefined, check which overload you called.

Trade-off: instantiateStreaming in the worker skips a message but repeats the compile

Calling WebAssembly.instantiateStreaming(fetch(url), imports) directly inside each worker is less wiring, and for a small module (under ~100KB) the difference is noise. Past a few hundred kilobytes, every worker pays full compilation again. The rule of thumb: one or two short-lived workers with a small binary can fetch for themselves; a pool, or anything over roughly 250KB, should receive a pre-compiled module.

Step 3 — Move data through linear memory

A WebAssembly instance sees exactly one flat byte array — its linear memory — addressed by 32-bit integer offsets. Nothing outside that array is reachable from inside the module, which is what makes the sandbox airtight and also what forces an explicit data-movement step: a transferred ArrayBuffer cannot become linear memory. Linear memory is its own allocation, created by the engine, so input has to be copied into it and output copied out. On typical hardware a TypedArray.set runs at several gigabytes per second, so an 8.3MB 1080p RGBA frame costs on the order of a millisecond each way — worth knowing, worth measuring, and not worth panicking about relative to a 90ms compute.

Where you copy to matters. Writing to a hard-coded offset works only for toy modules; anything with an allocator (Rust, C++, AssemblyScript, or any Emscripten build) keeps its own bookkeeping inside the same address space, and scribbling over it corrupts the heap in ways that surface much later. Ask the module for an address instead.

// wasm-worker.ts — reserve, write, run, read back, release
function runJob(ex: WasmExports, input: ArrayBuffer, width: number, height: number): void {
  const byteLen = width * height * 4;             // RGBA
  const inPtr = ex.alloc(byteLen);
  const outPtr = ex.alloc(byteLen);

  try {
    // Re-read `.buffer` AFTER alloc(): the allocator may have grown memory,
    // which detaches any view captured earlier.
    new Uint8Array(ex.memory.buffer, inPtr, byteLen).set(new Uint8Array(input));

    ex.process_frame(inPtr, outPtr, width, height);

    // Copy the result out into a buffer the worker owns, so it survives
    // dealloc() and can be transferred without pinning wasm memory.
    const result = new ArrayBuffer(byteLen);
    new Uint8Array(result).set(new Uint8Array(ex.memory.buffer, outPtr, byteLen));

    self.postMessage({ type: 'RESULT', buffer: result, width, height }, [result]);
  } finally {
    ex.dealloc(inPtr, byteLen);
    ex.dealloc(outPtr, byteLen);
  }
}
Where one job's bytes sit inside linear memory A byte-addressed strip running from offset zero to memory.buffer.byteLength. At the low end sit the module's static data and its stack. Above them the allocator hands back two blocks: inPtr and outPtr, each width times height times four bytes. The remaining tail is free capacity that the next allocation may grow into, and tick marks below the strip divide it into 64 kibibyte pages. The transferred input ArrayBuffer is copied into the inPtr block with TypedArray.set, and the outPtr block is copied out into a fresh result ArrayBuffer that is transferred back to the page. A transferred ArrayBuffer can never itself become linear memory, so one copy in each direction is unavoidable. input: transferred ArrayBuffer owned by the worker, outside the sandbox result: fresh ArrayBuffer survives dealloc(), transferred back copy in — TypedArray.set() copy out — before dealloc() linear memory — memory.buffer static data stack frames inPtr w × h × 4 bytes outPtr w × h × 4 bytes free capacity the next alloc() may grow into here offset 0 inPtr outPtr memory.buffer.byteLength Each tick is one 64 KiB page. A transferred ArrayBuffer cannot become linear memory — one copy in, one copy out. Never write to a hard-coded offset: the allocator's own bookkeeping lives in this same address space.
The allocator owns the address space, so alloc() — not a fixed offset — decides where the job's bytes go. Everything outside the strip is unreachable from inside the module.
Trade-off: two copies buy you safety

Copying the result out before dealloc costs one extra pass over the bytes. Handing back a view directly over memory.buffer avoids that pass but leaves the caller holding a window onto memory the module will reuse for the next job — and one that detaches the moment anything grows. Copy out unless you have measured that the copy is a real bottleneck, and if you skip it, guarantee no further module call happens before the bytes are consumed.

Step 4 — Grow memory without reading a detached view

Linear memory starts at initial × 64KiB and can grow a page at a time. Memory.prototype.grow(delta) returns the previous size in pages on success and throws a RangeError on failure — the -1 return that circulates in older articles is the behaviour of the raw memory.grow instruction inside the module, not of the JavaScript API. The consequential part is what growth does to your views: for unshared memory the engine detaches the old ArrayBuffer and installs a new one, so every typed array you built over the old buffer is instantly useless.

const PAGE = 65536;

/** Ensure at least `bytes` of capacity. Returns a FRESH view — never cache one. */
function heapFor(memory: WebAssembly.Memory, bytes: number): Uint8Array {
  const shortfall = bytes - memory.buffer.byteLength;
  if (shortfall > 0) {
    try {
      memory.grow(Math.ceil(shortfall / PAGE));   // throws RangeError if it cannot
    } catch (err) {
      throw new Error(
        `wasm memory exhausted: needed ${bytes} bytes, have ${memory.buffer.byteLength}`,
        { cause: err },
      );
    }
  }
  return new Uint8Array(memory.buffer);           // buffer identity may have changed
}
Trade-off: reserve up front or grow on demand

A generous initial makes every job allocation-free and removes detachment hazards entirely, at the cost of committing that memory for the tab's lifetime — and 32-bit WebAssembly caps out at 4GiB of address space regardless. Growing on demand keeps the resident set small but means any module call can invalidate your views. Whichever you choose, treat memory.buffer as a value you re-read rather than a reference you keep, and always declare a maximum so a runaway allocation fails fast instead of consuming the device.

Growing unshared memory detaches every existing view Before the call, a Uint8Array view reads through buffer A, which holds four pages, or 262144 bytes, and is the current value of memory.buffer. Calling memory.grow with a delta of four makes the engine install a brand new buffer B of eight pages, or 524288 bytes, and detach buffer A, whose byteLength becomes zero. The original view still points at buffer A, so every read through it now returns nothing. Only a freshly constructed Uint8Array over memory.buffer sees buffer B, which is why memory.buffer must be re-read after any call that could grow memory. Before memory.grow(4) view = Uint8Array built over memory.buffer reads ArrayBuffer A — 4 pages byteLength 262144 — the current memory.buffer memory.grow(4) — unshared memory: the engine installs a NEW buffer and detaches the old one After memory.grow(4) the same view still bound to buffer A zeros buffer A — detached byteLength 0 buffer B — 8 pages byteLength 524288 a fresh view new Uint8Array(buffer) re-read memory.buffer after any growth Shared memory is the exception: growing it never detaches, so existing views stay valid at their old length.
The hazard is not grow() itself but the views you kept: an internal allocation inside the module can grow memory without your JavaScript ever calling grow.

Emscripten builds compiled with -sALLOW_MEMORY_GROWTH hide this behind glue that refreshes HEAPU8 and friends after every growth — but only for the views the glue owns. Any view you created from Module.HEAPU8.buffer is still stale. The same applies to wasm-bindgen: call wasm.memory.buffer again after each boundary crossing rather than hoisting it into a module-level constant.

Step 5 — Share one linear memory across a pool

When several workers must operate on one dataset — four threads filtering four bands of the same frame, or a simulation stepping a shared particle array — copying the data to each of them defeats the purpose. Create the memory once with shared: true, pass it to every worker alongside the compiled module, and import it into every instance. All of them then address the identical bytes.

// main.ts — one module, one memory, N workers
const memory = new WebAssembly.Memory({
  initial: 64,     // 64 × 64KiB = 4MiB
  maximum: 512,    // required for shared memory; 32MiB ceiling
  shared: true,    // backed by SharedArrayBuffer — needs cross-origin isolation
});

const threads = navigator.hardwareConcurrency ?? 4;
const pool = Array.from({ length: threads }, (_, index) => {
  const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
  worker.postMessage({ type: 'INIT_SHARED', module: compiled, memory, index, threads });
  return worker;
});
// wasm-worker.ts — claim disjoint tiles with an atomic counter
async function initShared(
  module: WebAssembly.Module,
  memory: WebAssembly.Memory,
  index: number,
  threads: number,
): Promise<void> {
  const instance = await WebAssembly.instantiate(module, { env: { memory } });
  const ex = instance.exports as unknown as WasmExports & {
    process_tile: (tile: number) => void;
    tile_count: () => number;
  };

  // Word 0 of the shared heap is a reserved cursor the module never touches.
  const cursor = new Int32Array(memory.buffer, 0, 1);
  const total = ex.tile_count();

  for (;;) {
    const tile = Atomics.add(cursor, 0, 1);   // returns the value BEFORE the add
    if (tile >= total) break;
    ex.process_tile(tile);
  }

  self.postMessage({ type: 'TILES_DONE', index });
}
COOP / COEP are mandatory for shared memory

new WebAssembly.Memory({ shared: true }) is backed by a SharedArrayBuffer, so the document must be cross-origin isolated: serve it with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and make every cross-origin subresource CORP- or CORS-eligible. Without isolation the constructor throws before any of the code above runs. Header configuration and the failure signatures are covered in SharedArrayBuffer & Atomics, and the diagnostic path in Debugging SharedArrayBuffer Cross-Origin Errors.

Shared memory changes the growth rules in your favour: growing it does not detach anything. Existing views stay valid and simply keep their old length, so you re-create a view only to see the newly added bytes. It also changes the blocking rules against you — a WebAssembly memory.atomic.wait (and Atomics.wait in JavaScript) traps on the main thread, because blocking there is forbidden. Any barrier, mutex or condition-variable protocol your module uses must therefore only ever run on worker threads, which is one more reason the compute lives here.

One shared memory, eight tiles, four workers and an atomic cursor A single WebAssembly.Memory created with shared true, backed by a SharedArrayBuffer and imported by every instance in the pool. Word zero of the heap is a reserved Int32 cursor that the module never touches; the rest of the strip is divided into eight numbered tiles. Tiles zero to three are already finished. Each of the four workers has claimed exactly one of the remaining tiles — worker zero owns tile four, worker one tile five, worker two tile six and worker three tile seven — by calling Atomics.add on the cursor, which returns the index before the increment so no tile is ever handed out twice. Growing shared memory never detaches existing views. One WebAssembly.Memory { shared: true } — imported by every instance in the pool Worker 0 process_tile(4) Worker 1 process_tile(5) Worker 2 process_tile(6) Worker 3 process_tile(7) Atomics.add(cursor, 0, 1) returns the value before the add cursor Int32 @ 0 tile 0 done tile 1 done tile 2 done tile 3 done tile 4 W0 tile 5 W1 tile 6 W2 tile 7 W3 Word 0 is reserved for the cursor; the module never touches it. Every tile is claimed by exactly one worker. grow() on shared memory never detaches — existing views stay valid at their old length. Atomics.wait traps on the main thread, so every barrier in this protocol must run on a worker.
A single reserved word turns the pool into a work-stealing queue: each worker atomically claims the next index and stops when it passes tile_count(). No message passing is involved at all.

For coordination beyond a work-stealing counter — completion barriers, blocking queues, backpressure between a producer and a consumer thread — the patterns in Sharing WASM Linear Memory Across Workers and the ring-buffer designs in SharedArrayBuffer & Atomics apply unchanged; the fact that the bytes happen to be a module’s heap makes no difference to the algorithms.

Step 6 — Type the boundary

WebAssembly.Exports is an index signature of unknown, so TypeScript gives you no help at all with the one interface most likely to drift when someone rebuilds the binary. Declare the shape you expect and validate it once at instantiation — a missing export should fail loudly at startup, not as exports.process_frame is not a function in the middle of a job.

interface WasmExports {
  memory: WebAssembly.Memory;
  alloc: (byteLen: number) => number;
  dealloc: (ptr: number, byteLen: number) => void;
  process_frame: (inPtr: number, outPtr: number, width: number, height: number) => void;
  checksum64: (ptr: number, byteLen: number) => bigint;   // i64 crosses as BigInt
}

function assertExports(e: WebAssembly.Exports): asserts e is WasmExports & WebAssembly.Exports {
  const fns = ['alloc', 'dealloc', 'process_frame', 'checksum64'] as const;
  for (const name of fns) {
    if (typeof e[name] !== 'function') {
      throw new TypeError(`wasm module is missing export "${name}" — rebuild mismatch?`);
    }
  }
  if (!(e.memory instanceof WebAssembly.Memory)) {
    throw new TypeError('wasm module does not export its memory');
  }
}

At the ABI level only four value types cross the boundary: i32, i64, f32 and f64. JavaScript sees i32, f32 and f64 as number and i64 as bigint, so an export returning a 64-bit checksum yields a BigInt that will throw if you try to mix it with a number in arithmetic. Pointers are plain i32 values — small unsigned integers that mean nothing outside their own memory — so never let a pointer from one instance reach another. Strings, structs and arrays have no representation at all: they are always a (pointer, length) pair plus an agreed encoding, which is precisely the marshalling code that wasm-bindgen and Emscripten’s embind generate for you.

Keep the glue in the worker

If your toolchain emits a JavaScript glue file, import it inside the worker rather than on the main thread. Emscripten's glue in particular installs its own onmessage handling for pthreads builds and expects to own the module's memory views; splitting it across threads produces confusing failures. The main thread should know only the job message shape, never the module's ABI.

Data-Transfer Strategy

Four ways exist to get bytes to a module inside a worker, and the right one depends on payload size, how many threads touch the data, and whether the sender still needs its copy.

Strategy Cost for an 8.3MB frame Sender keeps data? Use when
Structured clone (plain postMessage) ~10–20ms, both threads pay Yes Payloads under ~100KB; job descriptors, parameters, metadata
Transferable ArrayBuffer Constant time, well under 1ms No — source detaches One-way large payloads that the worker copies into linear memory
Shared WebAssembly.Memory Zero — no copy at all Shared Several workers on one dataset, or ongoing read/write coordination
Write straight into linear memory Zero — data is already there n/a Chained passes where the output of one export feeds the next

The default for a single worker is the second row plus one copy: transfer the ArrayBuffer in, set() it into the region the allocator handed you, run the export, copy the output into a fresh buffer, transfer that back. Two memcpys at a few GB/s, and nothing serialized. The general mechanics of ownership handoff, and the detachment rules that come with it, are covered in Transferable Objects & Zero-Copy.

// main.ts — one job, promise-shaped, with transfers in both directions
function runOnWorker(
  worker: Worker,
  pixels: ArrayBuffer,
  width: number,
  height: number,
): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();

    const onMessage = ({ data }: MessageEvent) => {
      if (data.id !== id) return;                 // not ours — a pooled worker serves many jobs
      worker.removeEventListener('message', onMessage);
      data.type === 'RESULT' ? resolve(data.buffer) : reject(new Error(data.message));
    };
    worker.addEventListener('message', onMessage);

    // `pixels` is detached here: byteLength becomes 0 on this thread.
    worker.postMessage({ type: 'RUN', id, input: pixels, width, height }, [pixels]);
  });
}
Four routes for an 8.3MB payload into a module inside a worker Four rows, each showing what physically moves. Structured clone with a plain postMessage serialises the buffer into a second copy on the worker side and then copies that into linear memory: two copies, roughly 10 to 20 milliseconds, and the sender keeps its data. A transferable ArrayBuffer moves ownership with no serialisation and then needs one memcpy into linear memory: well under a millisecond plus one copy, and the source buffer detaches. A shared WebAssembly.Memory is addressed directly by every instance, so nothing is copied and nothing is owned by one thread. Writing straight into linear memory, where one export's output region feeds the next export's input, copies nothing because the bytes never leave the module. Strategy What actually moves Cost / ownership Structured clone plain postMessage input clone copy memcpy linear memory ~10–20 ms · 2 copies sender keeps its copy Transferable buffer postMessage(msg, [buf]) detached transfer buffer memcpy linear memory <1 ms · 1 copy the source detaches Shared Memory shared: true + COOP/COEP W0 W1 W2 one SharedArrayBuffer — every instance addresses these bytes zero copies no owner — all share In-place chaining output feeds the next export process_a() writes region @ ptr reads process_b() zero copies never leaves the module
Only the top row pays for serialization. Rows two to four differ in how many memcpys remain, which is why the default single-worker recipe is a transfer plus exactly one copy in each direction.

Two things do not change with size. Job metadata — parameters, tile descriptors, feature flags — should just be cloned normally alongside the transferred buffer; structured clone of a small object is measured in microseconds. And a transferred buffer is gone: if the job fails and you want to retry, the main thread no longer has the input. Either keep a copy before transferring (paying a full memcpy) or have the worker return the input buffer on the failure path, which costs nothing extra.

Verification & Measurement

A compiled rewrite is only a win if you can show the numbers, and “it feels faster” is not one. Measure in this order:

  1. Bracket the round trip on the main thread. performance.now() immediately before postMessage, and again when the result message has been applied to the DOM or canvas. That gap is what the user experiences.
  2. Have the worker report its own compute time. Record t0 at the top of the message handler and post the elapsed time back with the result. Round trip minus compute is your transfer and scheduling overhead; for a few megabytes it should stay in the low single-digit milliseconds.
  3. Report instantiation separately. The READY message from Step 2 carries instantiateMs. If it is more than a millisecond or two, a worker is compiling rather than instantiating — the module clone is not reaching it.
  4. Confirm the work is on a worker track. Open the Chrome Performance panel and check that the compute block appears under a Worker lane, not Main. Profiling Worker CPU Usage with the Chrome Performance Tab walks through reading those lanes, and Chrome DevTools Worker Debugging covers attaching a debugger to worker scope in the first place.
  5. Watch the Frames track during interaction. The pass condition is that no frame exceeds 16.7ms while the job runs. A worker rewrite that still drops frames is usually copying on the main thread — check the transfer list.
  6. Compare against the JavaScript baseline on the same input. Keep the old implementation behind a flag and run both over a fixed fixture, comparing a checksum of the output. A faster module that changes the pixels is not an optimisation.
// wasm-worker.ts — self-instrumented job handler
if (data.type === 'RUN') {
  const t0 = performance.now();
  const result = runJob(exports!, data.input, data.width, data.height);
  const computeMs = performance.now() - t0;
  self.postMessage({ type: 'RESULT', id: data.id, buffer: result, computeMs }, [result]);
}

Rough magnitudes to calibrate expectations, measured as warm steady-state on a mid-range laptop — treat them as ratios rather than promises, since the gap between hand-written JavaScript and compiled code narrows sharply for memory-bandwidth-bound work:

Workload JavaScript WebAssembly WebAssembly + SIMD
4K RGBA 3×3 convolution ~180ms ~55ms ~15ms
1M-element float32 dot product ~95ms ~28ms ~8ms
FLAC decode, 4MB file ~310ms ~90ms ~40ms
Binary record decode, 2MB ~40ms ~18ms n/a
1080p RGBA grayscale (bandwidth-bound) ~12ms ~10ms ~7ms

That last row is the honest one: a pass that does almost no arithmetic per byte is limited by memory bandwidth, and no instruction set fixes that. When a candidate workload looks like it, the copy in and out of linear memory may cost more than the module saves — keep it in JavaScript in a worker, as described in Image Processing in Workers.

One instrumented job: 6 ms of overhead around 90 ms of compute A to-scale bar for a single 8.3MB frame job whose main-thread round trip is 96 milliseconds. A thin slice at the start covers postMessage, the transfer out and the copy into linear memory, 1.8 milliseconds in total; a thin slice at the end covers the copy out, the transfer back, scheduling and applying the result to the canvas, 4.2 milliseconds in total. Between them the WebAssembly compute occupies 90 milliseconds, or 94 percent of the round trip. Below, the same job is expanded into its six measured spans: postMessage and transfer out at 0.3 milliseconds, memcpy into linear memory at 1.5, the process_frame export at 90.0, memcpy out to a fresh buffer at 1.5, transfer back and scheduling at 0.7, and applying to the canvas on the main thread at 2.0. Round trip minus the worker's self-reported compute is the transfer and scheduling overhead. One job, instrumented on both threads: t0 → t1 = 96.0 ms 1.8 ms 4.2 ms WebAssembly compute — 90.0 ms (94% of the round trip) t0 — postMessage t1 — pixels on screen the same job expanded — spans below are not to scale postMessage + transfer out 0.3 ms memcpy into linear memory 1.5 ms wasm export process_frame() 90.0 ms memcpy out to a fresh buffer 1.5 ms transfer back + scheduling 0.7 ms apply to canvas on the main thread 2.0 ms copy in — 1.8 ms compute — 90.0 ms return + paint — 4.2 ms Round trip minus the worker's self-reported compute is your transfer and scheduling overhead. If that gap runs into double digits for a few megabytes, something is still copying on the main thread.
Instrument both ends: the worker reports its own compute, the page brackets the round trip. The difference between them is the only number a transfer-strategy change can move.

Failure Modes & Error Handling

WebAssembly failures are unusually well-typed: the error class tells you which stage broke.

Error Stage Typical cause and fix
TypeError: Incorrect response MIME type Compile Server sends anything but application/wasm; fix the host, keep the arrayBuffer() fallback from Step 1
CompileError Compile Truncated or corrupt binary, or a post-MVP feature the engine has not enabled; the message names the offending byte offset
LinkError Instantiate An import declared by the binary is missing or of the wrong kind; the message names the module and import — check the linker’s import list
RangeError from memory.grow Runtime Growth beyond maximum or beyond the 4GiB address space; declare a realistic maximum and fail the job gracefully
RuntimeError: unreachable Runtime The module hit a trap — a Rust panic!, a C++ assertion, or an out-of-bounds access; rebuild with debug symbols to get a name in the stack
Silent zeros or stale pixels Runtime A typed-array view captured before a growth; re-read memory.buffer (Step 4)
ReferenceError: SharedArrayBuffer is not defined Startup Document is not cross-origin isolated; check self.crossOriginIsolated and the response headers in DevTools → Network

Errors thrown inside a worker do not reach the page’s window.onerror, so wire up both global handlers in worker scope and flatten anything you post — Error instances are cloneable in modern engines, but stack preservation is inconsistent, and a RuntimeError from a trap carries the most useful part in its message.

// wasm-worker.ts — never let a failure vanish into a silent thread
function toWire(err: unknown) {
  const e = err as Error;
  return { name: e?.name ?? 'Error', message: String(e?.message ?? err), stack: e?.stack };
}

self.addEventListener('error', (e) => {
  self.postMessage({ type: 'FAILED', error: toWire(e.error ?? e.message) });
});
self.addEventListener('unhandledrejection', (e) => {
  self.postMessage({ type: 'FAILED', error: toWire((e as PromiseRejectionEvent).reason) });
});

Retry policy needs one WebAssembly-specific caveat. A module that has trapped is not automatically broken — traps unwind cleanly and the instance stays usable — but a module that has corrupted its own heap (a bad pointer, a write past an allocation) will keep producing garbage. Treat RuntimeError as a signal to discard the instance and re-instantiate from the still-valid compiled module, which costs under a millisecond and is far cheaper than tearing down the worker. Only respawn the worker if instantiation itself fails. The wire format for shipping these failures to a backend is generalised in Structured Error Serialization Across Threads.

Error class maps one-to-one onto lifecycle stage Four lifecycle stages in sequence, each with the failure it raises and the matching recovery. Fetch raises a TypeError about an incorrect response MIME type; the fix is the server's Content-Type header, with the arrayBuffer fallback kept in place. Compile raises a CompileError for a truncated binary or a feature the engine has not enabled; the message names the offending byte offset, so rebuild. Instantiate raises a LinkError when a declared import is missing or of the wrong kind; check the linker's import list against the imports object. Running raises a RuntimeError for a trap or a RangeError when growth passes the declared maximum; discard the instance and re-instantiate from the still-valid compiled module, which costs under a millisecond, and respawn the worker only if instantiation itself fails. The error class names the stage that broke — and the stage names the fix Lifecycle stage fetch network or cache compile compileStreaming() instantiate module + imports run call an export How it fails TypeError incorrect response MIME type CompileError truncated binary, or a feature not enabled LinkError an import is missing or of the wrong kind RuntimeError · RangeError a trap, or growth past the declared maximum What to do fix the Content-Type and keep the arrayBuffer() fallback rebuild the binary; the message names the offending byte offset check the linker's import list against the imports object discard the instance and re-instantiate — under 1 ms from the same Module Traps unwind cleanly, so a RuntimeError never condemns the worker — only a corrupted heap does.
Only the last column is a runtime decision; the first three are build or deploy bugs that should never reach production twice. Respawn the worker itself only when instantiation is what failed.

Finally, guard the pool against jobs the user no longer wants. Version every job, drop results whose version is stale, and — because a RUN message queues behind whatever the worker is currently executing — keep at most one outstanding job per worker so cancellation is meaningful. Sizing and queueing that pool is the subject of Worker Pool Management.

Browser Compatibility

Feature Chrome Firefox Safari Edge
WebAssembly MVP 57 52 11 16
WebAssembly in workers 57 52 11 16
instantiateStreaming / compileStreaming 61 58 15 16
WebAssembly.Module structured clone 61 52 15 16
Threads: Memory({ shared: true }) + atomics 74 79 15.2 79
BigInt ↔ i64 integration 85 78 14.1 85
Bulk memory operations 75 79 15 79
Fixed-width SIMD (v128) 91 89 16.4 91
Exception handling 95 100 15.2 95

The MVP baseline has been universal for years, so the practical floor for a single-worker module is effectively “any browser still in support”. The gating features are shared memory (Safari 15.2, and only on cross-origin-isolated documents) and SIMD (Safari 16.4). Both should be feature-detected rather than assumed: probe shared memory with typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated, and probe SIMD by compiling a tiny module that uses a v128 instruction and catching the CompileError. Ship a scalar build as the fallback — a Promise.any race between two compileStreaming calls, or a simple capability check that picks a URL, keeps the branching to one place.

The Pipeline End to End

Everything above collapses into one shape: fetch and compile once, clone the compiled module into every worker, instantiate cheaply per thread, move bytes through linear memory in each direction, and transfer results back without a copy.

WebAssembly compile-once, instantiate-many pipeline The main thread compiles the WASM binary once, then distributes the compiled Module via postMessage to multiple workers, each of which instantiates cheaply and runs compute in parallel. Main Thread fetch('/wasm/compute.wasm') network / cache compileStreaming() 10–50ms (one time) WebAssembly.Module structured-cloneable postMessage (Module) Worker A instantiate() — <1ms WASM compute Worker B instantiate() — <1ms WASM compute Worker C instantiate() — <1ms WASM compute Result ArrayBuffer transfer
Compile once on the main thread; postMessage the compiled WebAssembly.Module to N workers; each instantiates cheaply and runs compute in parallel. Results return as transferred ArrayBuffers.

Going Further

Running a module on several cores has two shapes, and the deployment decides more than the algorithm does. Emscripten pthreads vs Manual Worker Orchestration compares a shared-heap pthreads build — which requires cross-origin isolation without exception — against independent instances over partitioned data, with the memory, debugging and blast-radius trade-offs of each.

Frequently Asked Questions

Can I pass a compiled WebAssembly.Module across postMessage?
Yes. A WebAssembly.Module is serializable, so you can compile it once and postMessage it to as many same-origin workers as you like. Each worker instantiates its own copy without re-downloading or re-compiling the binary, which matters as soon as the module is more than a few hundred kilobytes or the pool is more than two or three workers. What you cannot do is post a WebAssembly.Instance — instances are bound to one agent and are not serializable.
Does WebAssembly run faster inside a worker than on the main thread?
No — throughput is identical, because the same tiering compilers produce the same machine code in both places. The benefit is occupancy: a 200ms WebAssembly call on the main thread blocks layout, paint and input for those 200ms, while the same call on a worker leaves every frame deadline intact. Choose WebAssembly for speed and a worker for responsiveness; they solve different problems.
What headers are required for shared WebAssembly memory?
A WebAssembly.Memory created with shared: true is backed by a SharedArrayBuffer, so the document must be cross-origin isolated: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, with every cross-origin subresource served as CORP-eligible. Without isolation the Memory constructor throws. Shared memory also requires an explicit maximum page count.
How do I supply imports such as a logging function to a module inside a worker?
Pass an imports object as the second argument to WebAssembly.instantiate, keyed by module name then import name — usually { env: { … } }. Inside a worker you can wire up console, performance, crypto and fetch, but nothing that touches the DOM. Every import the binary declares must be present and of the right kind, or instantiation fails with a LinkError.
Why do my typed-array views read zeros after the module allocates more memory?
Growing an unshared WebAssembly.Memory detaches the old ArrayBuffer and replaces it with a new one, which silently invalidates every Uint8Array or Float32Array you created earlier. Re-read memory.buffer after any call that might grow — including calls into the module itself, since an internal allocation can grow memory without your JavaScript asking for it.

See also