Transferring ArrayBuffers to Workers Without Copying

This page answers one task: a WebAssembly module runs in a worker and processes large buffers — images, audio, files — and passing them back and forth with postMessage should move the memory instead of duplicating it on every hop.

Prerequisites

  • [ ] A Wasm module running in a Web Worker.
  • [ ] Inputs and outputs held in ArrayBuffers or typed arrays.
  • [ ] Optionally, a wrapper such as Comlink, as in wrapping a Wasm worker with Comlink.

Cloning versus transferring

postMessage sends data between threads with the structured clone algorithm by default: the receiving thread gets a deep copy. For a 48 MB image that means allocating 48 MB more and copying every byte, on the sending thread, before the message is even queued — tens of milliseconds of main-thread time and a doubled memory peak.

Some objects are transferable, and ArrayBuffer is the most important of them. Listing a buffer in the transfer list moves it: the receiving thread gets an ArrayBuffer backed by the same memory, and the sender’s buffer becomes detached — length zero, unusable. No bytes are copied; the cost is constant regardless of size. The trade-off is ownership: after transferring, the sender no longer has the data, which is exactly right for inputs handed off to a worker and results handed back.

Structured clone versus transfer for a 48 MB buffer Cloning allocates a second buffer and copies every byte on the sending thread, doubling memory. Transferring moves ownership of the same memory to the receiver in constant time and detaches the sender's buffer. structured clone (default) receiver gets a copy 48 MB copied on the sender peak memory doubles fine for small data transfer receiver gets the same memory constant time, no copy sender's buffer detached large inputs and results

Step 1 — transfer inputs into the worker

const bytes = new Uint8Array(await file.arrayBuffer());       // 48 MB
worker.postMessage({ type: "process", bytes }, [bytes.buffer]);   // transfer list
console.log(bytes.byteLength);                                 // 0 — detached

The second argument lists the buffers to transfer. The message itself can still contain the typed array; it arrives in the worker as a typed array over the transferred buffer. Transfer the underlying ArrayBuffer, not the typed array — bytes.buffer — and be aware that a typed array that is a view into a larger buffer transfers the whole buffer.

Step 2 — get the data into Wasm memory once

Inside the worker, the input must reach the module’s linear memory. That copy is unavoidable unless the data is produced inside linear memory in the first place — WebAssembly can only access its own memory — but it happens once, in the worker, off the main thread:

// worker
self.onmessage = ({ data }) => {
  const { bytes } = data;
  const ptr = wasm.alloc(bytes.length);
  new Uint8Array(wasm.memory.buffer, ptr, bytes.length).set(bytes);    // one copy, in the worker
  const outLen = wasm.process(ptr, bytes.length);
  …
};

With wasm-bindgen, passing bytes to a function taking &[u8] performs the same copy. Streaming the input in chunks avoids holding it twice, as shown in streaming file uploads into Wasm memory.

For repeated jobs, the worker can also reuse one input allocation in linear memory, growing it only when a larger input arrives, so the steady state involves no allocation in the module at all.

Step 3 — transfer results back out

The result sits in linear memory, and linear memory’s buffer cannot be transferred — it belongs to the module, and transferring it would detach the module’s entire memory. So copy the result into a fresh ArrayBuffer and transfer that:

  const out = new Uint8Array(wasm.memory.buffer, outPtr, outLen).slice();   // copy into a new buffer
  wasm.free(outPtr, outLen);
  self.postMessage({ type: "done", out }, [out.buffer]);                     // transfer, no second copy

slice() creates an independent buffer with exactly the result’s bytes. Transferring it to the main thread costs nothing more. Without the transfer list, postMessage would clone it — a second full copy, this time also blocking the worker. wasm-bindgen’s Vec<u8> return values are already copied out into a fresh Uint8Array, so they can be transferred directly.

A buffer's journey through a Wasm worker The main thread transfers the input buffer to the worker without copying. The worker copies it once into linear memory. The module processes it. The worker copies the result into a new buffer and transfers that back to the main thread without copying. main: file bytes transfer → 0 copies worker: input buffer copy into Wasm Wasm processes in linear memory worker: slice result copy out once main: result transfer → 0 copies

Step 4 — avoid detaching data you still need

Transferring is a move. If the main thread still needs the original — to display it while the worker processes, or to retry on failure — either keep a copy deliberately (bytes.slice() before transferring) or design the flow so the result replaces the original. A frequent bug is transferring a buffer that is also referenced elsewhere — an image’s pixel data that a canvas or cache still uses — and finding it empty afterwards. Detached buffers throw TypeError: Cannot perform … on a detached ArrayBuffer on most operations, which makes the mistake visible quickly.

ImageBitmap, OffscreenCanvas, MessagePort, ReadableStream and VideoFrame are transferable too, which makes them useful for moving media to a worker without copying. With Comlink, wrap transferable arguments and results in Comlink.transfer(value, [buffers]); otherwise Comlink clones them like plain postMessage would.

const result = await api.process(Comlink.transfer(bytes, [bytes.buffer]));

When shared memory is better than transfer

Transfer moves ownership back and forth, which suits request–response workloads: hand off an input, get back a result. Workloads where both threads need the same data at the same time — the main thread rendering frames that the worker keeps updating, an audio thread consuming samples continuously — fit shared memory better. A SharedArrayBuffer, or a Wasm module whose memory is shared, lets both threads see the same bytes with no messages at all, coordinated with Atomics. The price is cross-origin isolation and careful synchronisation, as described in sharing memory between Wasm and Web Workers. For most applications — process this file, encode this recording, resize this photo — transfer is simpler, needs no special headers, and gives nearly all of the performance benefit. Reach for shared memory only when profiling shows that per-message transfer or the copies into and out of linear memory are still a bottleneck.

Measuring that transfer actually happened

It is easy to believe data is being transferred when it is being cloned — the code works either way, only slower. Verify it. After postMessage, check that the sender’s buffer reports byteLength 0: if it still has its full length, it was cloned. In DevTools’ Performance panel, a cloned message shows up as a long postMessage task on the sender proportional to the data size, while a transfer is a sliver. Memory snapshots taken during processing show one copy of the data rather than two. Adding a development-only assertion in the wrapper that sends messages — “every ArrayBuffer larger than 1 MB in the payload must be in the transfer list” — catches regressions when someone adds a new field to a message and forgets to list it. Libraries that serialise messages for you, such as Comlink, need the same attention: their transfer helper must wrap every large argument and return value, or the library quietly falls back to cloning.

Expected output

Posting a 48 MB image to the worker takes under 0.1 ms on the main thread instead of about 35 ms; the main thread’s buffer reports byteLength 0 after the call; and the processed result arrives back as a transferred Uint8Array with no main-thread copy.

Gotchas

  • Transferring a typed array instead of its buffer. List typed.buffer in the transfer list.
  • Transferring Wasm memory’s buffer. It would detach the module’s memory. Copy the result out first.
  • Using the buffer after transfer. It is detached. Keep a copy if you still need it.
  • Sub-views transferring the whole buffer. A small view over a large buffer moves all of it. Slice first if needed.
  • Transferring buffers owned by a cache. Other code finds them empty later. Transfer only buffers you own outright.
  • Forgetting the transfer list on the way back. Results are cloned instead. Transfer them too.

Performance note

For a 48 MB buffer in Chrome, structured cloning to a worker blocked the main thread for 34 ms; transferring took 0.04 ms. The worker’s copy into linear memory took 9 ms off the main thread. Returning a 12 MB result by clone cost the worker 8 ms; by transfer, 0.02 ms.

Main-thread cost of sending a 48 MB buffer to a worker Milliseconds the main thread spends in postMessage for a 48-megabyte ArrayBuffer, with structured cloning and with transfer. ms on the main thread structured clone 34 ms transfer 0.0 ms

Frequently Asked Questions

Is transfer truly zero-copy? For ArrayBuffer, yes — engines move the backing store. Some objects may still copy internally, but buffers do not.

Can I transfer a SharedArrayBuffer? It does not need transferring; posting it shares it. It cannot appear in a transfer list.

Does transfer work between windows and iframes? Yes, postMessage to other windows supports transfer lists too.

Can Wasm memory itself be passed to a worker? Only shared memory; a non-shared WebAssembly.Memory cannot be posted, and its buffer should not be transferred.

What happens to a typed array whose buffer was transferred? It becomes a zero-length view. Indexing it returns undefined, and most methods throw.

Can I transfer the same buffer twice in one message? No. Listing a buffer twice, or transferring an already detached buffer, throws a DataCloneError.

← Back to Zero-Copy Data Transfer Patterns