How to Pass Large Arrays Without Blocking the UI
Sending a multi-megabyte array to a worker with postMessage freezes the UI for the whole duration of the copy — the fix is to move the array’s memory instead of duplicating it, which takes constant time no matter how big the payload is.
This is the narrow, practical case of the ownership model described in Transferable Objects & Zero-Copy, which sits inside Web Workers Architecture & Communication. The copying behaviour you are escaping is specified by the structured clone algorithm; everything below assumes you have read a flame chart and seen it there.
The symptom is specific and easy to confirm: a long yellow block labelled Serialize or StructuredClone on the main thread flame chart, sitting directly under your postMessage call, wide enough to drop frames. The width scales linearly with payload size, which is the giveaway — a transfer would be a hairline regardless of size.
Structured clone runs at roughly 1.2 ms per MB on a 2023-class laptop (V8, Chrome 124) and blocks the posting thread for that entire time. Transferring the same ArrayBuffer costs about 0.08 ms whether it holds 1 MB or 500 MB, because only a pointer changes hands. Anything above ~1 MB per frame belongs in the transfer list.
Minimal Reproducible Example
Three short files: an allocation, a transfer out, and a transfer back. Nothing else is required — no bundler, no library, no headers.
// main.ts — the sending side
const worker = new Worker(new URL('./sum-worker.js', import.meta.url), { type: 'module' });
// One contiguous allocation. 10 million float64 values = 80 MB of bytes.
const samples = new Float64Array(10_000_000);
for (let i = 0; i < samples.length; i++) samples[i] = i * 0.5;
const t0 = performance.now();
// The second argument is the transfer list. Note it holds `samples.buffer`,
// NOT `samples` — the view is not transferable, the buffer behind it is.
worker.postMessage({ type: 'SUM', buffer: samples.buffer }, [samples.buffer]);
console.log('postMessage took', (performance.now() - t0).toFixed(2), 'ms'); // ~0.08 ms
console.log('sender byteLength:', samples.buffer.byteLength); // 0 — detached
worker.onmessage = (event: MessageEvent<{ total: number; buffer: ArrayBuffer }>) => {
// Ownership came back with the result, so the same 80 MB can be reused.
const returned = new Float64Array(event.data.buffer);
console.log(event.data.total, returned.length); // 24999997500000 10000000
};
// sum-worker.js — the receiving side, plain JS because module type matters here
self.onmessage = (event) => {
if (event.data.type !== 'SUM') return;
// No copy happens here either: the view is a window onto the transferred bytes.
const view = new Float64Array(event.data.buffer);
let total = 0;
for (let i = 0; i < view.length; i++) total += view[i];
// Hand the memory back, again by transfer, so the main thread can refill it.
self.postMessage({ total, buffer: view.buffer }, [view.buffer]);
};
Run it and the postMessage timing prints a fraction of a millisecond. Delete the [samples.buffer] argument and the same line reports roughly 95 ms — the UI is frozen for six frames, and the sender still holds a live 80 MB array while the worker allocates its own.
Step-by-Step Walkthrough
1. The allocation must be a single ArrayBuffer
Float64Array(10_000_000) reserves one flat 80 MB region. That is what makes O(1) handoff possible: the browser transfers the whole region by changing which agent owns it, and there is nothing to walk. A number[] of the same length is a heap object holding ten million element slots, with no single block of bytes to hand over, so it can only ever be cloned.
Pick the narrowest element type that preserves your data — Float32Array halves the payload against Float64Array at ~7 significant digits of precision, and Int32Array or Uint8Array are dramatically smaller again for counters, indices and pixel channels. Size is irrelevant to transfer cost but very relevant to allocation cost and worker memory.
2. The transfer list is the second argument, and it takes buffers
worker.postMessage({ type: 'SUM', buffer: samples.buffer }, [samples.buffer]);
Two things have to line up. The buffer must be reachable from the message (it is, as data.buffer), and the same object identity must appear in the transfer list. If it appears only in the message it is cloned; if it appears only in the transfer list it is detached but never delivered.
The modern spelling is equivalent and clearer when you also need other options:
worker.postMessage({ type: 'SUM', buffer: samples.buffer }, { transfer: [samples.buffer] });
Everything else in the message — the type string, any metadata you attach — is still structured-cloned, which is fine because it is a few dozen bytes. Transfer applies per entry in the list, not to the message as a whole.
3. The sender’s reference is dead the instant the call returns
Detachment is synchronous and unconditional. After the call, samples.buffer.byteLength is 0, samples.length is 0, and every read through the old view returns undefined — silently, with no exception. This is the single most common source of “my worker got the data but my chart went blank”: the render path was still reading the view it just gave away.
Make it loud in development:
function transferBuffer(worker: Worker, buffer: ArrayBuffer, type: string): void {
worker.postMessage({ type, buffer }, [buffer]);
if (import.meta.env?.DEV && buffer.byteLength !== 0) {
// Reached only when the buffer was cloned instead of transferred.
console.warn(`${type}: buffer was NOT transferred — check the transfer list`);
}
}
That assertion is worth keeping. A refactor that changes [buffer] to [view], or drops the second argument during a merge, degrades to a silent copy that no test catches — the results stay correct, only the frame rate regresses.
4. The worker rebuilds a view, it does not copy
new Float64Array(event.data.buffer) creates a view over the received bytes; the constructor only copies when you pass it an array-like or an iterable, not an ArrayBuffer. The element type must match what the sender wrote — reading Float64 bytes through an Int32Array produces garbage rather than an error, because the bytes are simply reinterpreted.
5. Transfer the buffer back to reuse the allocation
Ownership is one-way, so a worker that keeps the buffer leaves the main thread with nothing to write into next frame. Posting view.buffer back in the worker’s own transfer list returns the same physical memory, which turns a per-batch allocation into a permanent one. At high frequency this matters more than the transfer itself: allocating and releasing 80 MB every frame is real GC pressure, while ping-ponging one buffer generates none. For several buffers in flight at once, keep a free list and hand out recycled buffers — the pooling pattern in Worker Pool Management applies unchanged to buffers as well as to workers.
Gotchas & Edge Cases
Passing the view instead of the buffer silently clones. postMessage({ data: view }, [view]) throws a DataCloneError in some engines and quietly copies in others, because a TypedArray is not itself transferable. Always list view.buffer.
A subarray transfers the whole buffer. view.subarray(0, 1000).buffer is the entire backing store, not the 1000-element slice — you transfer and detach all of it, including the parts other views were reading. If you need to send a window of a larger buffer, copy it out first with view.slice(0, 1000), which allocates a fresh buffer you can transfer without touching the original.
Transferring the same buffer twice throws. Once detached, a buffer cannot be transferred again; the second attempt raises DataCloneError with a message about an already-detached ArrayBuffer. Guard the second hop rather than catching it after the fact:
function sendOnce(worker: Worker, buffer: ArrayBuffer): boolean {
if (buffer.byteLength === 0) return false; // already transferred elsewhere
worker.postMessage({ type: 'PROCESS', buffer }, [buffer]);
return true;
}
SharedArrayBuffer is not transferable — and does not need to be. It is cloned by reference: both threads end up with live views over the same bytes, so nothing is detached and nothing is copied. That is the right tool when both sides need concurrent access rather than a handoff, but it requires the page to be cross-origin isolated.
SharedArrayBuffer exists only in cross-origin-isolated contexts. The document must be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp; otherwise the constructor is undefined in the page and in every worker, and self.crossOriginIsolated reads false. Setup and the trade-offs are covered in SharedArrayBuffer & Atomics. Transfer needs no headers at all, which is why it remains the default answer for a one-way handoff.
Performance Note
The rule of thumb: structured clone costs about 1.2 ms per MB and transfer costs about 0.08 ms flat, so the crossover where transfer becomes worth the ownership discipline is around 1 MB — and by 10 MB it is not a choice, because the clone alone exceeds a full 16.7 ms frame.
| Payload | Structured clone | Transfer | Peak memory during handoff |
|---|---|---|---|
| 1 MB | ~1.2 ms | ~0.08 ms | +1 MB (clone) / +0 (transfer) |
| 10 MB | ~12 ms | ~0.08 ms | +10 MB / +0 |
| 50 MB | ~60 ms | ~0.08 ms | +50 MB / +0 |
| 100 MB | ~125 ms | ~0.08 ms | +100 MB / +0 |
Numbers are Chrome 124 on a 2023-class laptop; V8 version and CPU move them, but the shape — linear versus flat — does not. Measure your own with the harness in Measuring Structured Clone Cost with performance.now().
Two costs sit outside that table. Converting a number[] to a typed array is roughly 0.4 ms per million elements and happens once, on the main thread — do it at ingest, not at send time, if the data arrives from JSON.parse. And peak memory during a clone is genuinely doubled, so a 100 MB clone on a memory-constrained mobile device can fail where a transfer succeeds.
Before shipping, verify three things: that byteLength === 0 on the sender immediately after every transfer, that the worker returns the buffer rather than dropping it, and that a Performance recording shows no Serialize block wider than a millisecond under any postMessage call in the hot path. If the flame chart is still wide, the transfer list is not doing what you think it is.