ArrayBuffer.prototype.transfer() and postMessage transfer lists for fast data exchange with web workers
Structured clone copies data by default on every postMessage call, which costs noticeable time with large binary data. Transferable objects solve this through an ownership change instead of a copy. This article covers the postMessage transfer list, the newer ArrayBuffer.prototype.transfer(), and common pitfalls with detached buffers.
Table of Contents
- 1. The problem: expensive copying of large binary data
- 2. Structured clone as the default behavior
- 3. Transferable objects: ownership transfer instead of a copy
- 4. Practical example: transferring a large buffer
- 5. ArrayBuffer.prototype.transfer(): explicit transfer in the same context
- 6. Detached buffers as a common source of bugs
- 7. Performance: when the switch pays off
- 8. Practical case: a processing pipeline across multiple workers
- 9. Limits and recommendation
- 10. Summary
- 11. FAQ
1. The problem: expensive copying of large binary data
When data is exchanged via postMessage between the main thread and a web worker, the browser uses the structured clone algorithm by default, which creates a full copy of the passed data. For small objects this barely matters, but for large ArrayBuffers, for example image, audio, or sensor data in the megabyte to gigabyte range, the copy becomes a measurable performance problem.
The cost of copying scales directly with the data size and also briefly blocks the thread sending the message. It is exactly for this scenario that the concept of transferable objects was introduced, allowing an object's memory to be handed to the receiver without a copy.
2. Structured clone as the default behavior
Without an explicit transfer list, postMessage fully copies every ArrayBuffer passed to it. The result is that both the main thread and the worker afterward each hold their own, independent copy of the same data, which briefly doubles memory usage and increases transfer time proportionally to the data size.
For most use cases with small messages, this behavior is entirely sufficient and even safer, because both sides can work independently with their own copy without affecting each other. Switching to transfer only pays off once larger amounts of data are involved.
// main thread: default copy without a transfer list
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64 MB
const worker = new Worker("worker.js");
console.time("postMessage-copy");
worker.postMessage({ buffer });
console.timeEnd("postMessage-copy");
// buffer stays fully usable in the main thread,
// the worker receives a complete copy.
3. Transferable objects: ownership transfer instead of a copy
postMessage accepts a transfer list as its second parameter, an array of objects whose underlying memory is not copied but whose ownership is handed to the receiver. After the transfer, only the receiver has access to the data, the sender loses it entirely.
Transferable objects include ArrayBuffer, MessagePort, ImageBitmap, and OffscreenCanvas. TypedArrays like Uint8Array themselves are not directly transferable, but their underlying .buffer is, which is why with TypedArrays the buffer specifically needs to go into the transfer list, not the view itself.
4. Practical example: transferring a large buffer
In the following example, a large ArrayBuffer is handed to a worker via the transfer list. The decisive difference from the default behavior is the second parameter of postMessage, which explicitly states which objects should be transferred instead of copied.
After the call, the buffer is detached in the main thread, its byteLength is 0, and any read access on old TypedArray views throws no error but no longer returns any data. This is a deliberate safety feature that prevents two threads from uncontrollably modifying the same memory region at the same time.
// main thread
const buffer = new ArrayBuffer(64 * 1024 * 1024);
const worker = new Worker("worker.js");
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // -> 0, buffer is now detached
// worker.js
self.onmessage = (event) => {
const { buffer } = event.data;
console.log(buffer.byteLength); // -> 67108864, full access in the worker
};
5. ArrayBuffer.prototype.transfer(): explicit transfer in the same context
ECMAScript 2024 added ArrayBuffer.prototype.transfer(), a method that creates a new ArrayBuffer taking over the memory content of the original buffer, while the original buffer is immediately detached. Unlike a postMessage transfer, this also works within the same thread, for example to hand a buffer to another function while the calling function loses access to it afterward.
The transferToFixedLength() variant additionally forces the new buffer to no longer be resizable, even if the original buffer was created as resizable with maxByteLength. Both methods optionally accept a new byte length as a parameter, letting the resulting buffer be grown or shrunk at the same time as the transfer.
const original = new ArrayBuffer(16);
new Uint8Array(original).set([1, 2, 3, 4]);
const moved = original.transfer(); // ownership changes
console.log(original.byteLength); // -> 0, original is detached
console.log(moved.byteLength); // -> 16, data has been taken over
console.log(new Uint8Array(moved)); // -> Uint8Array(16) [1, 2, 3, 4, 0, ...]
6. Detached buffers as a common source of bugs
The most common bug around transferable objects happens when an old reference is accidentally accessed after the transfer, for example because a TypedArray view on the old buffer was cached in a closure. The access does not throw a runtime error, but silently returns empty or unexpected data, which makes debugging harder.
A good debugging approach is to explicitly check and log before every transfer whether a buffer is already detached, recognizable by a byteLength of 0 combined with the knowledge that the buffer previously had a larger length. Consistently clearing all references to the old buffer right after the transfer reliably prevents this class of bugs.
function safeTransfer(buffer) {
if (buffer.byteLength === 0) {
throw new Error("Buffer is already detached, cannot transfer again");
}
return buffer.transfer();
}
const buf = new ArrayBuffer(8);
const moved = safeTransfer(buf);
try {
safeTransfer(buf); // -> throws, buf.byteLength is already 0
} catch (error) {
console.error(error.message);
}
7. Performance: when the switch pays off
With small amounts of data in the kilobyte range, the difference between copy and transfer is barely measurable, and the effort of an explicit transfer list usually is not worth it. From a few megabytes of data, for example video frames, audio buffers, or larger sensor datasets, the difference becomes clearly noticeable, since transfer takes nearly constant time instead of growing linearly with the size.
With very large amounts of data in the hundreds of megabytes or gigabyte range, a structured clone copy can also briefly cause noticeable stutter in the user interface, while a transfer completes practically instantly, since only a pointer to the same memory region is passed, no bytes are actually copied.
8. Practical case: a processing pipeline across multiple workers
A typical practical case is image or audio processing in a pipeline of several workers, where raw data is passed from one worker to the next, for example for decoding, filtering, and subsequent encoding. Without transfer, every step would incur a full copy, unnecessarily extending the total runtime of the pipeline.
Combined with OffscreenCanvas, rendering itself can even be offloaded to a worker, and a finished ImageBitmap can be handed back to the main thread via transfer, allowing expensive image processing to run outside the UI thread without an expensive copy of the result at the end.
// main thread
const offscreen = canvas.transferControlToOffscreen();
const renderWorker = new Worker("render-worker.js");
renderWorker.postMessage({ canvas: offscreen }, [offscreen]);
// render-worker.js
self.onmessage = (event) => {
const ctx = event.data.canvas.getContext("2d");
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 100, 100); // rendering runs in the worker
};
9. Limits and recommendation
It is important to know that after a transfer, the original buffer is no longer usable in the sending context, meaning it is no longer available there for its own computations. If the buffer is still needed in the sender afterward, a real copy must be created instead and only that copy transferred.
As a recommendation: for small, occasional messages, structured clone is entirely sufficient. Once large amounts of binary data are moved repeatedly between threads, for example in streaming or processing pipelines, transferable objects should be used consistently, combined with clear ownership of which thread holds the data at any given time.
| Object / API | Transferable? | Behavior After Transfer | Typical Use |
|---|---|---|---|
| ArrayBuffer | Yes | byteLength becomes 0, detached | Binary data, sensor and media data |
| MessagePort | Yes | Port no longer usable in the sender | Direct communication channels between workers |
| ImageBitmap | Yes | Bitmap no longer usable in the sender | Image data between worker and main thread |
| OffscreenCanvas | Yes | Canvas control moves to the receiver | Rendering outside the UI thread |
| TypedArray View | No, only .buffer is transferable | The view itself is not transferred | Access to ArrayBuffer contents |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Transferable Objects: The Essentials at a Glance
Structured Clone
Default behavior of postMessage, fully copies data, cost scales with size
Transfer List
Second parameter of postMessage, transfers ownership instead of copying
ArrayBuffer.transfer()
ES2024 method for explicit transfer even within the same thread
Detached Buffer
byteLength becomes 0, any further use in the sender is excluded