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