Transferable Objects & Zero-Copy
Every postMessage call has a hidden cost proportional to the size of what you send, because the default transport is the structured clone algorithm: it walks the object graph, allocates a fresh copy in the receiving thread’s heap, and blocks the sender for the duration. For a control message that cost is unmeasurable. For a 50 MB Float32Array it is a dropped frame — and then another one when the result comes back. Transferable objects remove that cost entirely by moving ownership of a memory region instead of duplicating it, in time that does not depend on the size of the region. This guide is part of the Web Workers Architecture & Communication reference, and it covers the one optimisation that changes the shape of a worker pipeline rather than just its constant factor.
The Failure This Guide Prevents
A LiDAR inspection viewer streams point clouds into the browser. Each scan is 1.5 million points — position, intensity and classification — which the app packs into a Float32Array of roughly 24 MB and hands to a worker for decimation, bounding-box computation and viewport culling. The worker returns a decimated buffer that WebGL uploads as a vertex attribute. The architecture is right: the expensive maths is off the main thread, and the worker’s own compute time is a stable 6 ms per scan. Yet the viewer stutters every time a scan arrives, and the profile explains why the worker is not to blame:
- The main-thread track shows a 9 ms task with almost nothing in it except a
postMessagecall — no scripting of your own, just serialization. - A second, matching 9 ms task appears on the worker track before the handler runs, deserializing what the main thread just serialized.
- Peak memory doubles for the duration of every scan, because sender and receiver both hold a full 24 MB copy while the clone is in flight.
- The return leg repeats the whole thing in reverse, so a single scan costs roughly 30 ms of pure copying against a 16.7 ms frame budget.
- Garbage-collection pauses grow over a long session: every scan allocates and discards two large buffers, and the collector eventually stops the world to reclaim them.
None of that is worker overhead in the abstract — it is memory bandwidth. A large-buffer clone is bounded by how fast the machine can allocate and memcpy, which on typical laptop hardware lands somewhere between 2 and 5 GB/s once allocation and page faults are included; 24 MB each way therefore costs on the order of 10 ms per leg and scales linearly with the payload. Transferring the same buffer takes a constant fraction of a millisecond regardless of whether it holds 1 MB or 500 MB, because nothing is copied: the engine detaches the backing store from the sending realm and re-attaches it in the receiving one. The measurement side of this — proving the clone is the cost before you change anything — is covered in Measuring Structured Clone Cost with performance.now().
Prerequisites
Nothing here needs a flag or an experimental build, but a few things must be true before the pattern pays off:
- Your payload is already binary, or can be. Transfer moves
ArrayBufferbacking stores. An array of 1.5 million plain objects has to be packed into a typed array first; if you are still deciding on a wire format, Streaming JSON Parsing with Transferable Chunks covers doing the packing incrementally as data arrives. - Module workers.
new Worker(url, { type: 'module' })lets both threads import the same envelope types and keeps function names in stack traces. It needs Firefox 114+ if you support Firefox. - A typed envelope shared by both threads, so the transfer list and the message body cannot drift apart. The discipline is the same one described in Message Passing Strategies, and transfer makes it more important, not less: a renamed field that quietly stops matching the transfer list degrades into a silent copy rather than an error.
- A
messageerrorhandler on both sides. Deserialization failures do not firemessage, and a request whose reply never arrives will hold a pooled buffer hostage forever. - An understanding of what clone does, because transfer does not replace it — the rest of the envelope is still cloned normally. The Step-by-Step Guide to the Structured Clone Algorithm walks the traversal rules that apply to everything outside the transfer list.
- Optional:
ArrayBuffer.prototype.transfer()(Chrome 114+, Firefox 122+, Safari 17.4+) if you want to detach a buffer deliberately outside ofpostMessage— useful for enforcing single ownership inside your own code.
Step 1 — Allocate the Buffer Once and Fill It In Place
Zero-copy at the thread boundary is worthless if you copy three times getting there. The bytes you intend to send must already live in the backing store you are going to transfer, which means allocating a typed array of the exact final length up front and writing into it — not building a JavaScript array and converting at the end, and not concatenating typed arrays with set() into successively larger buffers.
// packing.ts — shared by both threads
export const STRIDE = 5; // x, y, z, intensity, class
export interface Point { x: number; y: number; z: number; i: number; c: number; }
/** Packs points into a single Float32Array whose buffer is the unit of transfer. */
export function packPoints(points: readonly Point[]): Float32Array {
// One allocation, exact size. `out.buffer` is what will move across the boundary.
const out = new Float32Array(points.length * STRIDE);
for (let n = 0; n < points.length; n++) {
const p = points[n];
const o = n * STRIDE; // write directly into the final backing store
out[o] = p.x; out[o + 1] = p.y; out[o + 2] = p.z;
out[o + 3] = p.i; out[o + 4] = p.c;
}
return out;
}
Two details matter more than they look. First, new Float32Array(n) allocates its own ArrayBuffer, so out.buffer is exactly n * 4 bytes with no other views on it — which is what makes it safe to hand away. Second, if you build the view over a larger shared buffer with new Float32Array(pool, byteOffset, length), transferring pool detaches every view on it, including ones other parts of your code still hold. One buffer per logical payload is the rule that keeps ownership legible.
Allocating the exact size requires knowing the count in advance. When it is unknown — a stream, a parser, a filter — you have three options, in increasing order of complexity: over-allocate and send length as a cloned field alongside the buffer; use a resizable buffer (new ArrayBuffer(n, { maxByteLength }), Chrome 111+ / Firefox 128+ / Safari 16.4+) and grow it in place; or emit fixed-size chunks and transfer each as it fills. The chunked approach is usually best because it also spreads the work across frames — see How to Pass Large Arrays Without Blocking the UI.
Step 2 — Hand Ownership Over in the Transfer List
postMessage takes the message as its first argument and a list of transferables as its second. Anything listed there moves; everything else in the envelope is cloned as usual. This is why the pattern composes so well with an ordinary typed protocol — you keep sending small readable objects, and only the bulk fields skip the copy.
// client.ts — main thread
export interface ScanRequest {
kind: 'scan';
id: number;
stride: number;
pointCount: number;
buffer: ArrayBuffer; // travels by reference in the transfer list
}
export function sendScan(worker: Worker, id: number, packed: Float32Array): void {
const msg: ScanRequest = {
kind: 'scan',
id,
stride: STRIDE,
pointCount: packed.length / STRIDE,
buffer: packed.buffer,
};
// Second argument is the transfer list: the raw ArrayBuffer, never the view.
worker.postMessage(msg, [packed.buffer]);
// From here on the main thread owns nothing: packed.length === 0.
if (packed.buffer.byteLength !== 0) {
console.warn('[transfer] scan %d was cloned, not transferred', id);
}
}
The transfer list is a list of Transferable objects, and a typed array is not one of them. Passing packed instead of packed.buffer throws DataCloneError: Value at index 0 does not have a transferable type. The view can still travel in the message body — it clones to a tiny descriptor pointing at the buffer you transferred — but the list itself only ever takes the backing store.
The instant postMessage returns, the sending thread’s buffer is detached. byteLength reads 0, packed.length reads 0, indexed access returns undefined rather than throwing, and any method that walks the bytes — set, subarray, fill, slice — throws a TypeError. That asymmetry is worth internalising, because the silent-undefined path is how a detached read gets mistaken for a data bug: a chart that renders NaNs is far more likely to be reading a buffer it gave away than to have received bad numbers.
Transfer buys constant-time hand-off at the price of single ownership. If the sending thread still needs the data — to redraw the current frame, to retry after a worker crash, or to diff against the next scan — transfer is the wrong tool and you must either clone deliberately, keep a second copy, or use shared memory. Reaching for slice() to "keep a copy and transfer the original" reintroduces exactly the memcpy you were trying to avoid, so make the decision once, at design time, rather than per call site.
Step 3 — Transfer the Result Back
A worker that receives a buffer owns it. If it replies with a cloned result, the buffer it was given is garbage collected inside the worker and the main thread has to allocate a fresh one for the next scan — you have halved the copying, not eliminated it. Post the buffer back in the worker’s own transfer list and ownership ping-pongs between the two threads with no allocation on either side.
// scan-worker.js — module worker
self.onmessage = ({ data }) => {
if (data.kind !== 'scan') return;
const { id, stride, pointCount, buffer } = data;
const points = new Float32Array(buffer, 0, pointCount * stride);
// Decimate in place: keep every Nth point, compacting toward the front.
const keepEvery = 4;
let write = 0;
for (let read = 0; read < pointCount; read += keepEvery) {
points.copyWithin(write * stride, read * stride, read * stride + stride);
write++;
}
// The reply reuses the same backing store — no new allocation anywhere.
self.postMessage(
{ kind: 'scan:done', id, stride, pointCount: write, buffer },
[buffer], // ownership returns to the main thread
);
};
self.addEventListener('messageerror', (e) => {
// A payload that failed to deserialize never reaches onmessage.
self.postMessage({ kind: 'scan:error', id: null, reason: String(e.data) });
});
copyWithin is doing real work here: compaction happens inside the buffer the main thread already allocated, so the decimated result is a prefix of the same memory rather than a second array. The reply carries the new pointCount as an ordinary cloned number, and the main thread builds a view over the returned buffer using that length. The buffer stays physically 24 MB; only the meaningful range shrinks. If you genuinely need a smaller allocation you can slice() in the worker, but that is a copy — usually worth it only when the result will be retained for a long time.
Transfer removes the size-proportional cost, not the hop. Each leg still queues a task on the receiving thread's event loop, and a full round trip costs roughly 0.1–0.5 ms even for an empty message. For a per-scan or per-frame pipeline that is negligible against a 16.7 ms budget. For a coordination protocol exchanging thousands of tiny messages per second it is the dominant cost, and shared memory with Atomics.notify (0.005–0.02 ms per signal) is the better instrument.
Step 4 — Recycle Buffers Through a Pool
Once ownership ping-pongs cleanly, the remaining cost is allocation. A pipeline that creates a fresh 24 MB buffer per scan generates large-object-space churn and periodic collector pauses even though no copying happens. A pool fixes this: pre-allocate a small number of buffers, lend one out per request, and reclaim it when the reply arrives. In steady state the pipeline allocates nothing.
The subtlety is that a lent-out buffer is detached locally, so the pool cannot hold a live reference to it while the worker owns it. It must track the slot, not the object, and re-seat the returned buffer into that slot.
// buffer-pool.ts
interface Slot { buffer: ArrayBuffer | null; inFlight: boolean; }
export class BufferPool {
readonly #slots: Slot[];
readonly #byteLength: number;
constructor(size: number, byteLength: number) {
this.#byteLength = byteLength;
this.#slots = Array.from({ length: size }, () => ({
buffer: new ArrayBuffer(byteLength),
inFlight: false,
}));
}
/** Returns a free slot index and its buffer, or null when the pool is saturated. */
acquire(): { index: number; buffer: ArrayBuffer } | null {
const index = this.#slots.findIndex((s) => !s.inFlight && s.buffer !== null);
if (index === -1) return null; // apply backpressure; do not allocate more
const slot = this.#slots[index];
slot.inFlight = true;
const buffer = slot.buffer!;
slot.buffer = null; // the local reference is about to detach
return { index, buffer };
}
/** Re-seats a buffer the worker transferred back. */
release(index: number, buffer: ArrayBuffer): void {
const slot = this.#slots[index];
if (!slot?.inFlight) return; // late or duplicate reply — ignore it
slot.buffer = buffer.byteLength === this.#byteLength ? buffer : new ArrayBuffer(this.#byteLength);
slot.inFlight = false;
}
/** Replaces a slot whose buffer was lost with the worker that owned it. */
reclaimLost(index: number): void {
const slot = this.#slots[index];
if (!slot) return;
slot.buffer = new ArrayBuffer(this.#byteLength);
slot.inFlight = false;
}
get available(): number { return this.#slots.filter((s) => !s.inFlight).length; }
}
acquire() returning null instead of allocating on demand is deliberate: a pool that grows under load is not a pool, it is a slow leak with extra steps. A saturated pool is a backpressure signal, and the correct response is to stop producing — drop the frame, coalesce with the next one, or queue the request until a slot frees. The same credit-window reasoning applies as in any streaming protocol, and pool sizing interacts directly with worker count; Worker Pool Management covers picking both numbers together. As a starting point, size the pool at one buffer per worker plus one, which keeps every worker fed while capping resident memory at a number you can state out loud.
reclaimLost exists because ownership transfer has an unforgiving failure mode: if the worker dies while holding a buffer, that memory is freed with the worker and the slot would otherwise stay inFlight forever. Wire it to the worker’s error handler and to the request timeout, and the pool degrades by re-allocating one buffer rather than deadlocking.
A pool trades a fixed memory floor for zero steady-state allocation. Four 24 MB buffers pin 96 MB for the life of the page whether or not the pipeline is busy, which is a real cost on a 2 GB mobile device. Prefer a shallow pool (2–4 slots) plus backpressure over a deep one, and release the pool entirely when the feature is not on screen. If pooled buffers keep growing rather than being reused, the diagnosis is in Identifying Memory Leaks in Workers.
Step 5 — Transfer the Other Transferables
ArrayBuffer is the transferable everyone learns first, but the interface covers several object types, and the non-buffer ones remove even bigger costs because they carry GPU or decoder resources rather than plain bytes.
// Decode an image off the main thread and hand the decoded bitmap over.
const response = await fetch('/scans/overlay.png');
const bitmap = await createImageBitmap(await response.blob());
worker.postMessage({ kind: 'overlay', bitmap }, [bitmap]);
// `bitmap` is now closed on this thread: bitmap.width === 0.
// Give the worker exclusive draw access to a canvas for the rest of the page's life.
const canvas = document.querySelector('canvas')!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ kind: 'attach-canvas', offscreen }, [offscreen]);
// Hand one end of a private channel to a second worker so the two talk directly.
const { port1, port2 } = new MessageChannel();
decoder.postMessage({ kind: 'sink' }, [port1]);
renderer.postMessage({ kind: 'source' }, [port2]);
Each has its own ownership semantics, and they are not interchangeable:
| Transferable | What actually moves | Ownership after transfer | Typical use |
|---|---|---|---|
ArrayBuffer |
The backing store | Sender detached, byteLength === 0 |
Bulk numeric payloads, packed records |
ImageBitmap |
The decoded pixel handle, often GPU-resident | Sender’s bitmap is closed (width === 0) |
Image filters, texture upload without re-decode |
OffscreenCanvas |
The rendering surface | Main thread permanently loses 2D/WebGL context access | Chart and scene rendering off the main thread |
MessagePort |
The endpoint of a channel | Sender can no longer post on that port | Worker-to-worker links, per-feature routing |
ReadableStream / WritableStream / TransformStream |
The stream endpoint | Sender loses the endpoint; chunks cross as they flow | Piping fetch bodies straight into a worker |
AudioData / VideoFrame (WebCodecs) |
The media frame’s backing memory | Sender’s frame is closed | Real-time codec pipelines |
ImageBitmap is the highest-leverage of these for image work: createImageBitmap performs the decode once, and transferring the result means the worker never re-decodes and never touches ImageData’s four-bytes-per-pixel copy. Using Transferable Objects for Canvas ImageData covers the pixel-buffer route for cases where you need the raw samples instead.
OffscreenCanvas is the one to be careful with, because transferControlToOffscreen() is irreversible for the lifetime of that element: the main thread can never get a context back from that canvas, and calling getContext() on it afterwards throws. That is a feature when the worker owns rendering outright — see Transferring Canvas Control to a Worker — and a trap if any code path expected to draw an overlay from the main thread later.
ArrayBuffer ownership can ping-pong indefinitely; OffscreenCanvas cannot come back, and a closed ImageBitmap or VideoFrame cannot be reopened. Treat the non-buffer transferables as one-way architectural decisions, made once during setup, rather than per-message optimisations. If you need both threads to draw to the same visual output, use two stacked canvas elements instead of trying to share one.
Choosing Between Clone, Transfer and Shared Memory
There are exactly three ways to get data across a worker boundary, and mature pipelines use all three, selected per message class rather than per application.
| Situation | Mechanism | Cost profile | Who owns the bytes afterwards |
|---|---|---|---|
Small control message: start, cancel, config, IDs |
Structured clone | Microseconds; proportional to graph size | Sender keeps its copy |
| Bulk payload the sender is finished with | Transfer list | Constant, ~0.02–0.1 ms regardless of size | Receiver exclusively; sender detached |
| Bulk payload the sender still needs | Deliberate clone or slice() |
One memcpy, 2–5 GB/s | Both, independently |
| Data read continuously by two or more threads | SharedArrayBuffer + Atomics |
No copy, no hand-off; synchronisation cost only | All threads concurrently |
| Rendering surface | Transferable OffscreenCanvas |
One-off, permanent | Worker only |
The heuristic that resolves almost every case: clone what is small, transfer what is bulk and finished with, share what is read continuously by more than one thread. The middle case is the common one, which is why the transfer list — not shared memory — is the default optimisation for a worker pipeline. Shared memory removes the hand-off too, but replaces it with visibility and ordering problems you must solve yourself with SharedArrayBuffer & Atomics, and it drags a hard deployment requirement along with it. For a side-by-side decision rubric, postMessage vs SharedArrayBuffer: When to Choose Each walks the comparison with the same workload measured both ways.
One clarification that trips people up: transfer and clone are not exclusive within a single message. The transfer list applies only to the objects listed in it, while every other field of the same envelope is cloned normally. That is exactly what Step 2’s message does — a handful of cloned integers riding along with a 24 MB backing store that moves.
If you escalate from transferable buffers to SharedArrayBuffer, the document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and every cross-origin subresource must opt in through CORS or Cross-Origin-Resource-Policy. Without cross-origin isolation SharedArrayBuffer is undefined — the failure appears at construction, in same-origin workers too. Gate that path on globalThis.crossOriginIsolated === true and keep the transferable path as the fallback, because isolation is routinely broken later by an embed or an analytics tag nobody classified as a transport dependency. Transferable ArrayBuffers carry no such requirement and work on any page.
Verification & Measurement
A failed transfer does not throw — it silently degrades into a clone, and the only visible symptom is that the frame budget is gone again. Three checks catch that, in increasing order of rigour.
Assert detachment at the send site. A transferred buffer reports byteLength === 0 on the sending thread. Anything else means the buffer in the list was not the buffer in the payload.
export function postTransferring(
target: Worker | MessagePort,
msg: { buffer: ArrayBuffer },
label: string,
): void {
const expected = msg.buffer.byteLength;
target.postMessage(msg, [msg.buffer]);
if (msg.buffer.byteLength === expected && expected > 0) {
// Still intact ⇒ the transfer list did not include this exact buffer object.
console.warn('[transfer] %s copied %d bytes instead of transferring', label, expected);
}
}
Confirm the cost is flat in payload size. This is the definitive test, because it distinguishes constant-time transfer from linear-time clone without trusting any single measurement. Time the postMessage call across a size sweep — the clone curve rises linearly while the transfer curve stays flat.
for (const mb of [1, 4, 16, 64]) {
const buf = new ArrayBuffer(mb * 1024 * 1024);
const t0 = performance.now();
worker.postMessage({ kind: 'bench', buffer: buf }, [buf]);
console.log('%d MB → %.3f ms', mb, performance.now() - t0);
}
// Transferring: ~0.02–0.10 ms at every size.
// Cloning (drop the second argument): roughly 0.4–1.0 ms per MB, rising linearly.
Read the Performance panel for the shape, not the number. In a recording of the real pipeline, a working transfer looks like a short postMessage task on the sender followed almost immediately by the handler on the worker track. A clone looks like two long, near-identical tasks — one serializing, one deserializing — with a gap between them proportional to size. Attach to the worker isolate as described in Chrome DevTools Worker Debugging so both tracks are visible in the same recording.
Two more signals worth watching over a long session. Heap totals should stay flat across scans once the pool is in place: a sawtooth that climbs means buffers are being allocated per message rather than recycled. And the count of available pool slots should oscillate without trending downward — a monotonic decline is the signature of buffers being lost to worker restarts and never reclaimed, which will end in a saturated pool that never recovers.
Failure Modes & Error Handling
Four failures are specific to ownership transfer, and only one of them announces itself.
Reading a buffer you already sent. The most common bug and the quietest: indexed reads on a detached view return undefined, which becomes NaN the moment it enters arithmetic. There is no exception and no console warning. Any code that both sends a buffer and reads from it later needs an explicit guard, and the check is cheap enough to leave in production.
const isDetached = (b: ArrayBuffer): boolean => b.byteLength === 0;
function readSafely(view: Float32Array, index: number): number {
if (isDetached(view.buffer)) throw new Error('read after transfer: buffer is detached');
return view[index];
}
Sending the same buffer twice. A retry path, a duplicate event handler, or a pool slot released early will attempt to transfer an already-detached buffer, and this one does throw — DataCloneError: ArrayBuffer at index 0 is already detached. Wrap the send so the log identifies which request did it, and never swallow the error, because the request it belonged to will otherwise hang forever.
try {
worker.postMessage(msg, [msg.buffer]);
} catch (err) {
if (err instanceof DOMException && err.name === 'DataCloneError') {
console.error('[transfer] buffer for request %d was already given away', msg.id);
pool.reclaimLost(slotIndex); // the slot is unusable; replace it
}
throw err;
}
Non-detachable buffers. WebAssembly.Memory#buffer cannot be transferred at all — the spec marks it non-detachable, and attempting it throws DataCloneError. The same applies to a SharedArrayBuffer, which is shared by reference rather than transferred. When moving results out of a WebAssembly module, copy the region of linear memory into a fresh ArrayBuffer first and transfer that; the copy is unavoidable but it is bounded by the result size rather than the whole heap.
A worker that dies holding your memory. Transfer creates a hard dependency on the receiving thread staying alive. If the worker throws at top level, is killed under memory pressure, or is terminated mid-flight, the buffer goes with it and the sender has nothing to retry from. Every request that lends out a buffer therefore needs a timeout and an error path that reclaims the slot:
worker.addEventListener('error', (e) => {
console.error('[transfer] worker died holding %d buffers', pending.size, e.message);
for (const { slotIndex, reject } of pending.values()) {
pool.reclaimLost(slotIndex); // replace the lost memory
reject(new Error('worker terminated before returning buffer'));
}
pending.clear();
respawn();
});
Because the data is gone rather than merely delayed, retry semantics differ from an ordinary message protocol: the request cannot be replayed unless the sender still holds the source data in some other form. Decide up front whether a lost payload is regenerable — a scan that can be re-fetched — or fatal, and surface the difference in the UI rather than retrying blindly. The broader restart and telemetry patterns live in Main Thread vs Worker Thread Lifecycle.
Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Transferable ArrayBuffer in postMessage |
17+ | 18+ | 5.1+ | 12+ |
MessagePort transfer |
4+ | 41+ | 5+ | 12+ |
ImageBitmap transfer |
50+ | 42+ | 15+ | 79+ |
OffscreenCanvas transfer |
69+ | 105+ | 16.4+ | 79+ |
| Transferable streams | 87+ | 103+ | 16.4+ | 87+ |
structuredClone(value, { transfer }) |
98+ | 94+ | 15.4+ | 98+ |
ArrayBuffer.prototype.transfer() |
114+ | 122+ | 17.4+ | 114+ |
Resizable ArrayBuffer (maxByteLength) |
111+ | 128+ | 16.4+ | 111+ |
AudioData / VideoFrame transfer (WebCodecs) |
94+ | 130+ | 16.4+ | 94+ |
Module workers ({ type: 'module' }) |
80+ | 114+ | 15+ | 80+ |
SharedArrayBuffer (cross-origin isolated) |
92+ | 79+ | 15.2+ | 92+ |
The core pattern — Steps 1 through 4, packing into one buffer and ping-ponging ownership through a pool — has been universally supported for over a decade and needs no feature detection. The cliffs are all in the extensions: OffscreenCanvas transfer is the sharpest one at Firefox 105+, transferable streams and WebCodecs frames are recent enough to warrant a fallback path, and ArrayBuffer.prototype.transfer() is a convenience you can polyfill with a slice() plus an explicit hand-off. Build the pipeline on transferable ArrayBuffers, feature-detect the rest, and the same code ships to every engine in the table.
Going Further
The mechanism’s failure surface is narrow and unusual: a transferred buffer is not invalid, it is empty, and reads return undefined rather than throwing. Detached Buffer Errors and How to Avoid Them covers the four symptoms, the ownership conventions that prevent them, and a recycling protocol that hands buffers back so a long pipeline stops allocating.