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.

Performance

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.

The same postMessage call with and without the transfer list Without the transfer list the 80 MB buffer is duplicated into the worker heap, 160 MB is resident and the main thread blocks for about 95 milliseconds — roughly six frames. With the buffer in the transfer list the same allocation is remapped to the worker, the sender is detached at byteLength zero and the call costs about 0.08 milliseconds. No transfer list — structured clone Main thread heap samples.buffer — 80 MB memcpy 80 MB Worker heap a second 80 MB allocation 160 MB resident during the call ~95 ms blocked — six frames dropped Transfer list — ownership moves Main thread heap samples.buffer — 80 MB remap the pointer Worker heap the very same 80 MB 80 MB resident — sender detached, byteLength 0 0.08 ms — a hairline, no frame lost
The same call, one argument apart: the ticks on each timeline are 16.7 ms frame boundaries, so the clone spans six of them while the transfer never reaches the first.

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.

The two arguments of postMessage and the identity that links them The first argument is the message, which is structured-cloned; the second is the transfer list, which moves ownership. The same buffer object must appear in both: reachable from the message and listed in the transfer list, otherwise it is either cloned or detached without ever being delivered. One call, two arguments — only the second one moves memory Argument 1 — the message { type: 'SUM', buffer: samples.buffer } every field here is cloned same object identity Argument 2 — the transfer list [ samples.buffer ] buffers only — never a view a few dozen bytes of metadata copied plus a descriptor for the buffer 80 MB backing store handed over constant time, sender detached In the message only → the bytes are cloned. In the transfer list only → detached, and never delivered.
Transfer is per entry, not per message: the metadata still travels by clone, and only the buffer named in both places changes owner.

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.

Round trip of a single recycled buffer The main thread allocates once and transfers the buffer to the worker, which leaves the sender detached; the worker builds a view over the received bytes, sums them, and transfers the same buffer back; the main thread then refills that allocation for the next batch instead of allocating again. Main thread Worker 1. postMessage({ buffer }, [buffer]) 80 MB changes owner — sender now reads byteLength 0 sum over the view no copy on receipt 2. postMessage({ total, buffer }, [buffer]) the same physical memory comes home 3. refill and send it again Steady state: one allocation, zero copies per batch, nothing for the collector to reclaim
Ownership is one-way, so the return hop is not optional: without it the main thread has nothing to write into next frame and starts allocating 80 MB again.

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.

COOP / COEP required for SharedArrayBuffer

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.

Which transport the payload's shape forces on you Three columns compare structured clone, the transfer list and SharedArrayBuffer by payload shape, the constraint that decides the branch, cost per hop and when to reach for it: clone for non-contiguous values, transfer for an ArrayBuffer handed one way, and shared memory when both threads must read the same bytes at once under cross-origin isolation. The payload's shape picks the transport — not the other way round Structured clone PAYLOAD SHAPE number[], objects, Map, Set, plain records WHAT DECIDES IT no contiguous backing store to hand over COST PER HOP ~1.2 ms per MB, copied REACH FOR IT WHEN the payload stays small Transfer list PAYLOAD SHAPE the ArrayBuffer behind a typed array WHAT DECIDES IT ownership is one-way — the sender loses it COST PER HOP ~0.08 ms at any size REACH FOR IT WHEN one side owns a batch SharedArrayBuffer PAYLOAD SHAPE bytes both threads read while in use WHAT DECIDES IT needs COOP + COEP cross-origin isolation COST PER HOP nothing moves at all REACH FOR IT WHEN both sides read at once
Transfer is the middle column for a reason: it is the only one that moves megabytes for free and asks nothing of your response headers.

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.

Per-hop cost against payload size, 1 MB to 100 MB On log-log axes the structured-clone cost rises as a straight diagonal from 1.2 milliseconds at 1 MB to 125 milliseconds at 100 MB, while transfer stays flat at about 0.08 milliseconds at every size. The clone line crosses the 16.7 millisecond frame budget at roughly 14 MB; transfer never approaches it. Cost of one hop against payload size — both axes logarithmic 0.01 0.1 1 10 100 1000 cost per hop (ms) 1 MB 10 MB 100 MB payload size 16.7 ms — one frame clone blows the frame budget at ~14 MB structured clone — 1.2 ms per MB 1.2 ms 12 ms 60 ms 125 ms transfer — 0.08 ms at any size 0.08 ms at 1 MB the gap is already 15x
A straight diagonal against a flat line: clone cost tracks payload size exactly, transfer ignores it, and the frame budget is crossed somewhere around 14 MB.

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.

Frequently Asked Questions

Why does postMessage block the main thread when I send a large array?
Because the default path is the structured clone algorithm, which walks the value and deep-copies every byte into the receiving thread’s heap. That work happens synchronously on the calling thread, so a 10 MB Float64Array costs roughly 12 ms — most of a 16.7 ms frame — and briefly doubles peak memory while both copies exist. Listing the underlying ArrayBuffer in the transfer list skips cloning entirely: the browser remaps ownership of the memory in O(1), the call returns in well under a millisecond regardless of size, and the sender’s buffer is left detached with byteLength === 0.
Can I transfer a plain JavaScript Array instead of a TypedArray?
No. Only ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas and VideoFrame are transferable — a number[] is a heap object with boxed elements and no contiguous backing store, so it can only be cloned. Convert it once with new Float64Array(values) and transfer view.buffer. The conversion costs roughly 0.4 ms per million elements, and every subsequent hop is free, so the pattern pays for itself the moment the same buffer is reused or sent more than once.

See also