Detached Buffer Errors and How to Avoid Them

Transfer is the fastest way to move bytes between threads and the only one that changes the sender’s world: the moment postMessage returns, the buffer you passed is empty. Not invalid, not throwing — empty, silently, with every read producing undefined.

This is the failure surface of the mechanism described in Transferable Objects & Zero-Copy, part of Web Workers Architecture & Communication, and the conventions that keep it from happening.


What Detachment Actually Does

const pixels = new Uint8ClampedArray(1920 * 1080 * 4);
console.log(pixels.byteLength);          // 8294400
console.log(pixels[0]);                  // 0

worker.postMessage({ pixels }, [pixels.buffer]);

console.log(pixels.byteLength);          // 0     ← the view is now empty
console.log(pixels[0]);                  // undefined  ← not an error
console.log(pixels.buffer.byteLength);   // 0
pixels.set([1, 2, 3]);                   // TypeError: Cannot perform … on a detached buffer

The ArrayBuffer’s internal data pointer is set to null and its length to zero; ownership of the memory now belongs to the receiving agent. Every view over that buffer — including views you created earlier, in other modules, and handed to other code — becomes empty at the same instant, because they all reference the same buffer object.

The consequences follow from the second sentence of that paragraph. Detachment is not scoped to the variable you passed to postMessage; it is a property of the buffer, so any part of the application holding a view is affected without warning.

What transfer does to every view over a buffer Before the transfer, one ArrayBuffer of eight megabytes is referenced by three views held in different parts of the application: a pixels view in the render module, a header view in the parser, and a cached view in a store. After postMessage with the buffer in the transfer list, the buffer's data pointer is null and its length zero; all three views report zero length simultaneously, and indexed reads on any of them return undefined rather than throwing. The receiving worker now owns the memory. Detachment is a property of the buffer, not of the variable you passed before ArrayBuffer — 8 MB pixels (render) header (parser) cached (store) after postMessage(…, [buffer]) detached — byteLength 0 pixels — length 0 header — length 0 cached — length 0 worker owns it Three modules lost their data, and only one of them called postMessage.
The store's cached view is the dangerous one: nothing in its own code changed, and its next read returns undefined.

The Four Symptoms

Silent zeros or undefined. A chart renders blank, a hash comes out as the hash of nothing, a checksum is zero. The read succeeded; there was simply nothing there. This is the most common form and the hardest to trace, because the stack points at the reader rather than the transfer.

TypeError: Cannot perform Construct on a detached ArrayBuffer. Raised when constructing a new view over a detached buffer, which at least names the cause.

DataCloneError: An ArrayBuffer could not be cloned because it was detached. Raised when transferring the same buffer twice — usually a retry path that re-posts a request whose payload was already transferred.

A length that is zero where a length was expected. Loops that iterate for (let i = 0; i < view.length; i++) simply do nothing, so downstream code sees an empty result set rather than an error, and the failure surfaces several layers away.


Ownership Conventions That Prevent It

The mechanism cannot be made safe by checking; it has to be made safe by convention. Three rules cover essentially every case.

Transfer only what you constructed for the call. A buffer allocated immediately before postMessage, used for nothing else, and never referenced again is impossible to misuse. A buffer that came from application state is a hazard by construction.

Name transferred variables so the handoff is visible. A convention as light as a moved prefix, or a helper that returns void, makes review effective:

/** Posts and returns nothing, so the caller cannot keep using the payload. */
function sendOwned(worker: Worker, kind: string, payload: Uint8Array): void {
  worker.postMessage({ kind, payload }, [payload.buffer]);
  // Deliberately no return value. The caller's reference is dead.
}

Assert in development. A one-line check after every transfer converts a silent failure into an immediate one:

if (import.meta.env.DEV && payload.byteLength !== 0) {
  throw new Error('expected the buffer to be transferred — check the transfer list');
}

That assertion catches the opposite bug too, and it is a common one: a transfer list that was omitted or mistyped, so the payload was silently structure-cloned instead. Nothing else surfaces that mistake, because the code works — just slowly, with a copy of every byte.


Returning Buffers to Their Owner

Long pipelines transfer the same logical buffer back and forth, which is efficient and needs an explicit protocol. The trick is that the receiver returns the buffer when it is finished, so the sender can reuse the allocation rather than making a new one:

// page.ts — a pool of buffers that live by being handed back
const spare: ArrayBuffer[] = [];

function nextBuffer(bytes: number): Uint8Array {
  const buffer = spare.pop() ?? new ArrayBuffer(bytes);
  return new Uint8Array(buffer);
}

worker.addEventListener('message', (event: MessageEvent) => {
  const { result, recycled } = event.data as { result: Summary; recycled: ArrayBuffer };
  render(result);
  spare.push(recycled);                     // the worker gave it back
});

function submit(rows: number[]): void {
  const view = nextBuffer(rows.length * 8);
  view.set(rows as never);
  worker.postMessage({ kind: 'RUN', view }, [view.buffer]);   // ours no longer
}

The worker transfers the same buffer back alongside its result. The allocation count for the session stops growing, garbage-collection pressure falls, and — the part that matters here — there is never a moment where the page holds a view it believes is live and is not, because a buffer is either in the pool or in the worker, never both.

A buffer's ownership cycle between page and worker A cycle with four positions. In the page's spare pool, the buffer is owned by the page and unused. On submit, it is filled and transferred, at which point the page's view detaches and the worker owns the memory. The worker reads it, computes a result, and transfers the same buffer back alongside that result. On arrival the page pushes it into the spare pool again. A note records the invariant: a buffer is in exactly one of these positions at any moment, which is what makes accidental reads impossible. One buffer, one owner, always page: spare pool owned, idle page: filled, transferred local view detaches here worker: owns the memory reads, computes worker: transfers it back alongside the result No position overlaps another, so there is never a live view over memory someone else owns.
Recycling is usually adopted for allocation pressure; the ownership clarity it brings is the larger benefit in practice.

Diagnosing One in a Running Application

When the symptom is already in production — a blank chart, a zero checksum — the diagnosis is short if you know what to look for.

Check the length at the point of failure. console.log(view.byteLength) immediately before the read that produced nothing. A zero says detachment; a correct length says the bug is elsewhere and saves an hour of looking in the wrong place.

Search for every transfer list in the codebase. postMessage calls with a second argument are few, and one of them is the culprit. The buffer that ends up detached is usually shared with the failing view through a helper two or three layers away — a toTypedArray utility, a cache, a normalisation step that returned a view rather than a copy.

Log at the transfer, not at the read. A single line in the sending helper — id, byte length, and a stack — makes the ordering obvious in a log, and ordering is what this bug is about: the read happened after a transfer nobody expected to affect it.

function postOwned(worker: Worker, message: object, buffer: ArrayBuffer): void {
  if (import.meta.env.DEV) console.debug('transfer', buffer.byteLength, new Error().stack);
  worker.postMessage(message, [buffer]);
}

Reproduce with a smaller input. Detachment is deterministic and size-independent, so a four-element array reproduces it exactly as a four-megabyte one — which makes the failing path easy to step through rather than something that only happens on a real dataset.

The one diagnostic that does not work is a breakpoint on the failing read: by then the buffer has been empty for some time and nothing at that point records who emptied it. Instrument the transfer.


Diagnosing a detached buffer in four steps Four diagnostic steps. Log the byteLength at the failing read: a zero proves detachment and a correct length sends you elsewhere. Find every transfer list in the codebase, because there are few and one of them is the culprit. Log at the transfer rather than at the read, since ordering is the thing in question. Then reproduce with a tiny input, because detachment is size-independent and deterministic. Finding the transfer that emptied your view check byteLength zero means detached anything else, look elsewhere grep the transfer lists there are only a few one of them is it log at the transfer with a stack ordering is the question shrink the input four elements is enough it is deterministic A breakpoint at the failing read tells you nothing: by then the buffer has been empty for some time. Each step is cheap; skipping one is what makes the next expensive.
The third step is the one that actually finds it — the bug is about ordering, so instrument the earlier event.

Gotchas

structuredClone with a transfer list detaches too. structuredClone(value, { transfer: [buf] }) has the same effect as postMessage — useful for making a deliberate copy, and surprising if you expected a clone to be non-destructive.

Assuming a catch can recover it. Detachment is not reversible: there is no way to reattach memory that has moved. Recovery means re-obtaining the data from its source, which is why keeping that source is part of the design.

A DataView and a typed array over the same buffer both die. They are separate objects with separate byteOffsets and one shared buffer. Transferring via either detaches both.

Transferring a subarray’s buffer transfers everything. view.subarray(0, 100).buffer is the whole original buffer, not the 100-element window. There is no way to transfer part of a buffer; slice it into a new one first, which does copy.

Transferring to two workers. A buffer has one owner, so posting it to a second worker after the first throws DataCloneError. Fan-out needs either a copy per recipient or a SharedArrayBuffer, which is readable by all of them at once.

Retry paths re-transferring a dead payload. A supervisor that retries a failed request will find the payload detached and throw DataCloneError. Retries need either a fresh payload or a copy kept for the purpose — decide which when writing the retry, not when it first fires in production.

Transferring in a loop over a shared source. for (const chunk of chunks) worker.postMessage(chunk, [chunk.buffer]) is correct only if each chunk has its own buffer. Chunks produced by subarray() share one buffer, so the first iteration detaches every subsequent chunk and the loop throws on its second pass.

Frameworks holding views in state. A Float64Array stored in a signal, a ref or a store and later transferred leaves the framework holding an empty view, and the component that reads it renders nothing. Transfer only values that never entered application state.


Performance Note

Transferring an 8 MB buffer costs about 0.03 ms regardless of size, against roughly 9 ms to structure-clone the same data on a 2023 laptop — plus 8 MB of fresh allocation on the receiving heap. That ratio is why the mechanism is worth the ownership discipline. The recycling protocol above removed a further 11 ms per second of garbage-collection pause in a 60 fps pipeline, simply by allocating four buffers for the session instead of sixty per second.

Frequently Asked Questions

Why does reading a detached typed array return undefined instead of throwing?
Because indexed access on a typed array is defined to return undefined for any index that is out of bounds, and detachment sets the length to zero — so every index is out of bounds. It is not a special case for detachment; it is ordinary out-of-range behaviour applied to an array that has become empty. Only set(), subarray() and similar methods raise a TypeError.
How do I check whether a buffer has been transferred?
Test buffer.byteLength === 0, or view.byteLength === 0 for a view. A detached ArrayBuffer reports zero length in every engine, and there is no other observable difference — no flag, no exception on access. Modern engines also expose buffer.detached, which is clearer where it is available; the length check works everywhere.

See also