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.
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.
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.
Step 5 — other transferables and Comlink
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.bufferin 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.
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.
Related
- Avoiding copies when passing image buffers — copies inside one thread.
- Passing arrays between JavaScript and Wasm — the copy into linear memory.
- Resizing images off the main thread — a workload that depends on transfer.
- Keeping the UI responsive during long Wasm tasks — why the worker is there.
← Back to Zero-Copy Data Transfer Patterns