How to Transfer ArrayBuffers to Web Workers Without Copying
This guide refines Offloading Work to Web Workers with Comlink within Core Web Vitals & Measurement. Moving work to a worker removes its computation from the main thread — but the data still has to get there. By default, postMessage uses the structured clone algorithm: it serialises the data on the sending thread and deserialises it on the receiving one. For a 20MB image buffer or a large typed array of samples, the serialisation alone can take 20–60ms on the main thread, long enough to fail an interaction that triggered it.
Transferable objects avoid the copy. When you list an ArrayBuffer (or a MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream and a few others) in the transfer list, ownership moves to the receiving thread in constant time. The sender's reference becomes detached — its byteLength drops to zero — and the receiver gets the same memory without any copying.
Rapid Diagnosis
- Find postMessage costs in the trace. In the Performance panel, a "Serialize" or structured-clone block inside the task that calls
postMessage(or Comlink's proxy call) reveals copying. - Measure payload sizes. Log
buffer.byteLengthfor data sent to workers. Anything above a few hundred kilobytes is a candidate for transfer. - Check the receiving side too. Results sent back from the worker are cloned onto the main thread on receipt; a large result costs main-thread time on arrival.
- Look for objects containing typed arrays. A plain object holding several
Float32Arrays is cloned in full unless the underlying buffers are listed as transferables.
Root Cause Analysis
1. Default cloning. postMessage(data) without a transfer list always clones. Comlink calls are postMessage calls; arguments and return values are cloned unless wrapped with Comlink.transfer.
2. Results cloned back. The worker returns a large array or object; the main thread pays deserialisation on receipt, inside whatever task handles the message.
3. Non-transferable structures. Arrays of plain objects (JSON-like data) cannot be transferred, only cloned. Their clone cost scales with object count, not just bytes.
4. Accidental reuse after transfer. Code that transfers a buffer and then reads it gets a detached, zero-length buffer — often failing silently.
Step-by-Step Resolution
1. Transfer buffers with raw postMessage
const pixels = ctx.getImageData(0, 0, w, h).data.buffer; // ArrayBuffer
worker.postMessage({ type: 'blur', width: w, height: h, pixels }, [pixels]);
// pixels.byteLength === 0 now — the main thread no longer owns it.
// trade-off: after transfer the main thread cannot read the buffer. If you need
// to keep the original (for undo, say), copy it once deliberately with
// pixels.slice(0) and transfer the copy, accepting the copy cost explicitly.
Expected outcome: send cost drops to under a millisecond regardless of size.
2. Transfer arguments and results with Comlink
Comlink exposes Comlink.transfer(value, transferables) for both directions.
// main.js
import * as Comlink from 'comlink';
const api = Comlink.wrap(new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' }));
const out = await api.blur(Comlink.transfer(pixels, [pixels]), w, h);
// image-worker.js
Comlink.expose({
blur(buffer, w, h) {
const result = applyBlur(new Uint8ClampedArray(buffer), w, h);
return Comlink.transfer(result.buffer, [result.buffer]); // zero-copy back
},
});
// trade-off: Comlink.transfer marks a value for transfer on that single call.
// Forgetting it on the return value is the most common mistake — the result is
// silently cloned back onto the main thread.
Expected outcome: both directions are zero-copy; only small metadata is cloned.
3. Convert object arrays into columnar typed arrays
Large arrays of records clone slowly. Store numeric fields in typed arrays (one per column) so they can be transferred.
function toColumns(rows) {
const n = rows.length;
const price = new Float64Array(n), stock = new Int32Array(n);
rows.forEach((r, i) => { price[i] = r.price; stock[i] = r.stock; });
return { price, stock };
}
const cols = toColumns(rows);
worker.postMessage(cols, [cols.price.buffer, cols.stock.buffer]);
// trade-off: building columns costs a pass over the data on the main thread.
// Do it once when data arrives (or in the worker that fetched it), not on
// every interaction.
Expected outcome: payloads that were tens of milliseconds to clone become effectively free to send.
4. Fetch large data in the worker directly
The cheapest transfer is none. If the main thread only fetches data to hand it to a worker, let the worker fetch it instead; the response body never touches the main thread.
Expected outcome: no main-thread serialisation and no main-thread parsing — see moving heavy JSON parsing off the main thread.
Verification
Record the interaction that sends data to the worker. The structured-clone block should be gone from the main-thread task, and the task should be short regardless of payload size. Add an assertion in development builds that detects accidental cloning of large data:
if (import.meta.env.DEV) {
const orig = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (msg, transfer) {
const size = msg?.byteLength ?? msg?.buffer?.byteLength ?? 0;
if (size > 1_000_000 && !transfer?.length) console.warn('Large postMessage without transfer', size);
return orig.call(this, msg, transfer);
};
}
// trade-off: this only inspects top-level buffers; nested ones inside objects
// slip through. It is a development tripwire, not a guarantee.
SharedArrayBuffer: When Transfer Is Not Enough
Transfer moves ownership: only one thread can use the buffer at a time. If both threads need to read and write the same memory concurrently — a worker filling a ring buffer of audio samples while the main thread draws them — SharedArrayBuffer provides genuinely shared memory, coordinated with Atomics. It requires the page to be cross-origin isolated, which means serving Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless), and that can break third-party embeds that do not opt in. For most UI workloads, transferring buffers back and forth is simpler and sufficient; reach for shared memory only for continuous streaming workloads where the transfer round trip is itself the bottleneck.
Common Mistakes with Transferables
- Transferring a buffer still referenced by a view you keep using. Any
Uint8ArrayorDataViewover the transferred buffer becomes zero-length; reads returnundefinedand writes are silently dropped. Re-create views after receiving the buffer back. - Transferring the same buffer twice in one message. Listing a buffer twice in the transfer array throws a
DataCloneError. Deduplicate the transfer list when building it from several views. - Transferring buffers owned by a library. Some libraries keep internal references to buffers they hand you (canvas
ImageData, WebAssembly memory). Transferring those breaks the library; copy instead. - Forgetting the round trip. For a pipeline where the worker returns modified data, transfer in both directions — otherwise half the benefit is lost on the way back.
FAQ
Can I transfer a typed array directly?
You transfer its underlying ArrayBuffer (typedArray.buffer). The typed array object itself is cloned (cheaply — it is a small view) and on the receiving side it refers to the transferred buffer. Be careful with views over part of a larger buffer: transferring the buffer moves all of it, including bytes other views depend on.
Is transfer available everywhere?
Transferring ArrayBuffer and MessagePort is supported in all modern browsers. Transferring streams, OffscreenCanvas and some other types has narrower support; feature-detect or fall back to cloning for those.
Does Comlink proxy objects instead of copying them?
Only if you ask: Comlink.proxy(obj) sends a reference that turns method calls into messages. Proxied objects avoid copying but make every access a round trip, which is slow for data access. Use proxies for callbacks and transfer for bulk data.
How do I know whether cloning is actually my bottleneck?
Time it directly: wrap the postMessage call (or the Comlink call up to its first await) in performance.now() measurements on a throttled device. If the call itself takes more than a few milliseconds, cloning is significant. Also check the receiving side: a long task at the start of the worker's message handler, before your code runs, is deserialisation. For payloads under roughly 100KB, cloning is rarely worth optimising.
Related
- Comlink vs raw postMessage for workers — the abstraction cost and when to skip it.
- Running client-side search in a Web Worker — a complete worker workload.
- Avoiding main-thread image decode jank — moving image processing off-thread.