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.

Structured clone vs transfer Comparison of sending a large ArrayBuffer to a worker by structured clone, which copies on the main thread, and by transfer, which moves ownership in constant time. Structured clone vs transfer Structured clone (default) • Serialise 24MB on the main thread • Both threads hold a copy (48MB peak) • ~35ms main-thread cost on mid-tier mobile • Sender keeps using its buffer Transfer • Ownership moves, no serialisation • One copy in memory • Well under 1ms regardless of size • Sender's buffer is detached

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.byteLength for 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.

Main-thread cost of sending data to a worker (mid-tier phone) Bar chart of main-thread time to send different payloads to a worker by clone and by transfer. Main-thread cost of sending data to a worker (mid-tier phone) 4MB buffer, clone 7ms 24MB buffer, clone 36ms 24MB buffer, transfer 0.2ms 50k JSON objects, clone 61ms 50ms long task

Step-by-Step Resolution

1. Transfer buffers with raw postMessage

javascript
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.

Comlink exposes Comlink.transfer(value, transferables) for both directions.

javascript
// 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.

javascript
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.

Clone, transfer or share? Decision sequence for choosing between structured clone, transfer and SharedArrayBuffer when sending data to a worker. Clone, transfer or share? Payload under ~100KB? Clone — the cost is negligible yes no Is it an ArrayBuffer or typed array? Transfer it in the transfer list yes no Both threads need it at the same time? SharedArrayBuffer (needs cross-origin isolation) yes no Convert records to columnar typed arrays, then transfer

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:

javascript
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 Uint8Array or DataView over the transferred buffer becomes zero-length; reads return undefined and 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.

/html>