structuredClone is a built-in JavaScript function that makes a deep copy of a value. A deep copy duplicates the value and everything it contains, while a shallow copy would only duplicate the reference to it. The function uses the structured clone algorithm, the same mechanism browsers use to pass data between threads with postMessage and to store values in IndexedDB. The copy handles circular references (values that point back to themselves) and built-in types such as Map, Set, and Date. The function throws a DataCloneError when it meets a value it cannot copy, such as a function or a DOM node.
The second argument to structuredClone is an options object with a transfer array. A transferable object is a value whose backing memory the engine can hand to another context, such as a worker thread, without copying it. When you list a transferable object in the transfer array, the engine moves it into the clone instead of copying it. For a large ArrayBuffer, copying duplicates every byte, while transferring does not.
The move has a price: the original buffer stops being usable. Transferring detaches it, clears it, and makes any later access throw. The practical rule is to treat any buffer you list in transfer as gone from the moment the call returns. What follows shows when a transfer list is worth using. It also shows what breaks when code keeps using the original buffer, how to hand a buffer to a Web Worker and get it back, and how to find the line that detached a buffer you still needed.
What a transfer list actually does
When you call structuredClone(value, { transfer: [buffer] }), the engine copies everything in value that is not listed in the transfer array, and moves the listed ArrayBuffer into the result. The clone now points at the same underlying memory, and no second copy of those bytes exists. You can transfer any subset of the buffers you pass in; the engine can move arrayBuffer1 while it copies arrayBuffer2 normally.
The transfer is one-way and cannot be undone. Once the engine transfers the buffer, the original is detached. Its byteLength becomes 0, and any read or write through it throws a TypeError. The object also cannot be transferred again or used at all. A transferred object is detached from the original and attached to the clone, so the original no longer has the data.
You can check the state directly. ArrayBuffer.prototype.detached is a read-only accessor: it returns false when you create the buffer and true once the engine has transferred it. The accessor has been widely available in browsers since March 2024.
A typed array view such as Uint8Array does not protect you. The view reads and writes the buffer’s bytes as numbers, and the engine ties the view to the buffer’s memory. After the transfer, the view still exists but points at nothing; any read or write through it throws a TypeError. This example shows the full cycle:
const buffer = new ArrayBuffer(1024 * 1024);
const view = new Uint8Array(buffer);
view[0] = 7;
const clone = structuredClone({ data: buffer }, { transfer: [buffer] });
console.log(buffer.byteLength); // 0
console.log(buffer.detached); // true
console.log(clone.data.byteLength); // 1048576
try {
console.log(view[0]);
} catch (err) {
console.log(err.name); // "TypeError"
}
The engine destroys the original instead of copying faster for a reason. A Web Worker runs on a separate thread, and if both threads could reach the same memory, the engine would need locking primitives to stop the two threads from writing at the same time. Detaching the sender’s copy guarantees that only one context can touch the bytes at any moment, so no mutex is needed. Web Workers are a message-exchange model, not a shared-memory model; if you genuinely need two threads to read and write the same memory, use SharedArrayBuffer instead.
Why transfer beats copying for large buffers
Without a transfer list, the engine clones the whole data graph, and for a large payload it does that work on the calling thread, keeping the main thread busy. A transfer instead moves ownership of the underlying memory from one thread context to another in O(1) constant time, which means the time does not grow with the buffer size. That is why the transfer option exists: for big image buffers, audio samples, or any payload measured in megabytes, copying causes a visible pause and transferring does not.
Handing a buffer to a Web Worker and getting it back
Web Workers are scripts that run on a separate thread, and postMessage is how the main thread exchanges data with them. postMessage transfers or clones based on your choice: objects listed in the transfer list are transferred, not cloned, so the sending side loses them. The same rule applies to dedicated and shared workers, because both communicate through MessagePort objects.
The main thread transfers an 8 MB buffer to a worker, reads the buffer’s new state after the worker sends it back, and never copies the bytes:
// main.js
const worker = new Worker("worker.js");
const buffer = new ArrayBuffer(8 * 1024 * 1024);
new Uint8Array(buffer).fill(7);
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength); // 0
console.log(buffer.detached); // true
worker.onmessage = (event) => {
const result = new Uint8Array(event.data);
console.log(result[0]); // 9
};
The worker receives the actual buffer rather than a copy, writes new values using a typed array view, and transfers ownership back to the main thread:
// worker.js
self.onmessage = (event) => {
const buffer = event.data;
const view = new Uint8Array(buffer);
view.fill(9);
self.postMessage(buffer, [buffer]);
};
After that call, the worker’s reference is detached as well, so the worker must not touch the buffer again. If you omit the transfer list, postMessage clones the buffer on every send, so the main thread keeps its copy and the worker writes to a separate one. If the worker sends the result back, postMessage clones it a second time. The structured clone algorithm also lets you pass complex structures, such as objects containing strings, arrays, and nested values, directly to the worker without JSON.stringify.
Failure cases that break after a transfer
Using the original after the transfer. This is the failure the design intends. Any code that kept a reference to the buffer and reads or writes it after the transfer throws a TypeError, because the buffer has byteLength 0. The symptom appears at the use site, not at the transfer call, so the error often lands in a function that looks unrelated to the change you just made.
Transferring the same buffer twice. The transfer is one-shot. Once the engine has transferred a buffer, the original context no longer has the memory, so a second transfer list that names the same object has nothing to move and throws.
Writing to the buffer as if it were an array. An ArrayBuffer is not an array. One developer transferred a buffer from a worker to the parent with postMessage(NUMS, [NUMS]) and then found the values cleared; the actual mistake was writing with NUMS[0] = 10, which sets a property on the ArrayBuffer object and writes no bytes. You need a typed array view such as Uint8Array to access the bytes. The transfer exposed the bug because the expected values never appeared on the receiving side.
Detachment discovered after an await. One report shows a buffer that was valid at the AFTER PARSE and BEFORE HANDLER log points inside a uWebSockets message handler, a WebSocket server library for Node.js, then detached by the BEFORE REPUBLISH point. The thread that reported it did not reach a confirmed cause, but the mechanism rules out the await itself: only a transfer detaches a buffer. The practical check is to find which call between those log points transferred it.
How to debug code that breaks after a transfer
Start by confirming that detachment caused the failure. buffer.detached returns true after a transfer, and buffer.byteLength drops to 0, so logging both at the point of failure tells you whether the buffer was already gone. In runtimes without the accessor, treat byteLength === 0 as the same signal.
Then find every transfer in the code path. Search for the two call shapes that move a buffer: postMessage(message, [buffer]) and structuredClone(value, { transfer: [buffer] }). Every match is a point where the original buffer stops being usable. If the same buffer appears in more than one transfer list, the second call operates on a dead object.
Then trace ownership. After a transfer, only the receiving side may touch the buffer. The sending side must either drop its reference or wait for the receiver to transfer the buffer back, which the worker example does with self.postMessage(buffer, [buffer]). If the buffer was fine at one log point and detached at the next, the transfer happened between those lines; add a detached log at each step in the window, then inspect every call between the two points. If the failure is silent wrong data rather than an exception, suspect the wrong handle, such as indexing an ArrayBuffer directly instead of reading through a typed array view.