skip to content

A worker replies with `self.postMessage(u8, [u8])`, where `u8` is a `Uint8Array`, and the browser throws a `DataCloneError`. Why does that call fail, and what is the correct way to hand the bytes over without copying them?

level: middleimportance: should knowfreq 32%

answer

  1. Views are windows, buffers own memory
  2. Transfer list takes transferables only
  3. Reach through .buffer
  4. Whole buffer moves, not the slice
  5. Message argument and transfer list differ

basics

~20 s

Typed arrays are not transferable objects — only the ArrayBuffer behind them is. Passing a view in the transfer list throws DataCloneError. Transfer u8.buffer instead: postMessage(u8, [u8.buffer]), which sends the view and moves its memory.

solid answer

~40 s

The transfer list only accepts **transferable objects**, and a `Uint8Array` is not one — it is a view, a window onto an `ArrayBuffer`. The buffer is the transferable, so naming the view throws a `DataCloneError`. The fix is `self.postMessage(u8, [u8.buffer])`: the message is still the view, which the structured clone algorithm rebuilds on the other side over the transferred buffer, so the receiver gets a `Uint8Array` of the same length and offset with no copy. Two traps follow. First, if the view is a partial window — say `new Uint8Array(buf, 0, 10)` over a 1 MB buffer — you transfer the *whole* buffer, not the 10 bytes. Second, after the call every view over that buffer, on this side, is detached and reports `length` 0.

code

javascript · 12 lines
javascript
const u8 = new Uint8Array(1024);
u8[0] = 42;

try {
  structuredClone(u8, { transfer: [u8] }); // view is not transferable
} catch (err) {
  console.log(err.name); // "DataCloneError"
}

const moved = structuredClone(u8, { transfer: [u8.buffer] });
console.log(moved.constructor.name, moved.length, moved[0]); // Uint8Array 1024 42
console.log(u8.length); // 0 - the original view is detached

go deeper

for a junior

Remember that transfer works on ArrayBuffers, not on typed arrays, so reach through .buffer when you build a transfer list.

for a middle

Explain the view-versus-buffer split, why DataCloneError is thrown, and that the unit of transfer is the whole buffer regardless of the view's offset and length.

for a senior

Show that you would audit who else holds views over that buffer before transferring it, and that you decide between copy and transfer from measured serialization cost rather than by reflex.

for a principal

Set the convention for binary payload ownership across a codebase — arena allocation versus per-message buffers, who is allowed to transfer, and how the boundary is expressed so a detached-view bug cannot be written silently.

## Views versus buffers Binary data in JavaScript is split across two kinds of object. An `ArrayBuffer` is the raw block of memory. A **view** — `Uint8Array`, `Float32Array`, `DataView` and the rest — is a typed window onto some region of that block, defined by a byte offset and a length. Several views can look at the same buffer at once, at different offsets and with different element types. Only the buffer owns memory, so only the buffer can be transferred. The transfer list is defined in terms of **transferable objects**, and the list of those is closed: `ArrayBuffer`, `MessagePort`, `ImageBitmap`, `OffscreenCanvas`, `ReadableStream`, `WritableStream`, `TransformStream`. Typed arrays are not on it. Putting one there is a spec-level error, and the browser reports it as a `DataCloneError` — in Chromium the message reads roughly "Value at index 0 does not have a transferable type." ## The correct call ```js self.postMessage(u8, [u8.buffer]); ``` Read the two arguments separately. The **message** is `u8`, so structured clone will recreate a `Uint8Array` on the receiving side. The **transfer list** is `[u8.buffer]`, so the memory that view sits on is moved rather than copied. The receiver ends up with a `Uint8Array` of the same length, over the same bytes, having paid no per-byte cost. The same rule applies to `structuredClone`: ```js structuredClone(u8, { transfer: [u8] }); // DataCloneError const copy = structuredClone(u8, { transfer: [u8.buffer] }); // works ``` ## Trap one: you transfer the buffer, not the view's slice Because the unit of transfer is the buffer, a small view over a big buffer moves everything: ```js const big = new ArrayBuffer(1024 * 1024); const header = new Uint8Array(big, 0, 16); worker.postMessage(header, [big]); // 1 MB of ownership moves for a 16-byte view ``` The receiver sees a 16-byte view, but the whole megabyte has left this agent, and every other view over `big` is now dead. If you meant to send only those bytes, copy them out first — `header.slice()` produces a fresh view over a fresh 16-byte buffer, which you can then transfer, or simply clone, because at that size the copy is free. This is also why an old view is dangerous: `u8.buffer` may be shared with views you did not write. In a codebase that allocates one large arena and hands out sub-views, transferring any one of them detaches all of them. ## Trap two: detachment is silent on writes After the transfer, on the sending side: ```js u8.length; // 0 u8[0]; // undefined u8[0] = 5; // no error, no effect new Uint8Array(u8.buffer); // TypeError ``` Reads on a detached view return `undefined` and writes are discarded rather than throwing, so a loop that keeps filling a transferred buffer produces no diagnostic at all — just wrong results downstream. Constructing a new view over the detached buffer *does* throw a `TypeError`, which is often the first visible symptom, several frames after the real mistake. ## When not to bother Cloning a typed array is not exotic — structured clone handles every typed array and `DataView` natively, and the copy cost only matters at scale. Below a few tens of kilobytes, `postMessage(u8)` with no transfer list is the simpler and safer call, because nothing detaches and the sender can keep using its data. Reach for transfer when profiling shows serialization time on the main thread, or when the payload is large and genuinely one-way — decoded audio frames, image pixel data, a parsed binary asset. ## The reusable rule When you want zero-copy movement of binary data, ask two questions. *What is the transferable here?* — always the `ArrayBuffer`, reachable as `.buffer` from any view. *Who owns it after this call?* — the receiver, exclusively. If either answer is inconvenient, you either want a copy or you want shared memory, and both are different tools.

  • If you transfer `u8.buffer`, what exactly does the receiving side get as `event.data`?
    A `Uint8Array` — the same view type, length and byte offset as the one you sent. Structured clone serializes the view's shape and rebuilds it over the transferred buffer on the other side, so nothing about the typing is lost. Only the memory moved; the wrapper was reconstructed.
  • You have a 16-byte header view over a 1 MB buffer and want to send only the header. What do you do?
    Copy it out with `header.slice()`, which allocates a fresh 16-byte buffer, and send that — transfer it or just clone it, since 16 bytes is nothing. Transferring the original would move the entire megabyte and detach every other view over it.
  • Why does writing through a detached typed array not throw?
    Indexed access on a typed array goes through the integer-indexed exotic object semantics, where an out-of-bounds index reads as `undefined` and a write is discarded. A detached view has length 0, so every index is out of bounds. Operations that need the buffer itself, like constructing a new view or `slice()`, do throw a `TypeError`.

saying these in an interview costs you the question

  • Treating a Uint8Array itself as transferable
  • Assuming only the view's byte range moves
  • Thinking the receiver gets a bare ArrayBuffer, losing the view type
  • Keeping and reusing views after transferring their buffer
  • Believing every large typed array must be transferred

context