← Back to the library
FoundationsHow it works · Deep dive · 14 min

Multithreaded JavaScript: clone, transfer or share

If JavaScript is single-threaded, how do workers run in parallel, and what does it cost to move data between them?

Each JavaScript thread is its own agent with its own heap and event loop; parallelism comes from running several agents and passing data between them. You can copy data (structured clone), move ownership (transfer), or share raw memory (SharedArrayBuffer), and each step trades safety for speed.

THE MENTAL MODEL

Picture each worker as a separate small JavaScript program that happens to live in the same process. Nothing in its heap is visible to any other thread: objects, closures and prototypes never cross. The only bridge is a message channel, and you choose how bytes ride it. postMessage(value) serialises a deep copy that the receiver rebuilds, which costs CPU on both sides in proportion to size and object count. Adding a transfer list hands over the backing memory of an ArrayBuffer, MessagePort or OffscreenCanvas in constant time, leaving the sender with a detached, zero-length husk. A SharedArrayBuffer is the exception: both threads see the same bytes at once, so you get the speed of shared memory and all of its races, which is why you need Atomics for correctness and why browsers only allow it on cross-origin isolated pages where Spectre-style timing attacks cannot read other origins' data.

HOW IT FITS TOGETHER

Three ways to get data into a workerPick the cheapest mechanism that keeps the code correct. Most apps never need the third row.
Copy costOwnership after sendSync neededRequirementsGood for
postMessage (structured clone)O(size): serialise on sender, rebuild on receiverBoth sides own independent copiesNone; messages are queued eventsClonable values only: no functions, DOM nodes, class prototypesSmall or medium messages, commands, results
postMessage with transfer listConstant for the transferred buffer; the wrapper is still clonedMoves: sender's object is detached (byteLength 0)None; one owner at a timeTransferable types: ArrayBuffer, MessagePort, OffscreenCanvas, streamsLarge binary payloads, handing off a canvas or a port
SharedArrayBuffer + AtomicsZero after setup; both see the same bytesShared: every thread reads and writes concurrentlyYes: Atomics for counters, flags, wait/notifyBrowser: secure context plus COOP and COEP headersHot loops, ring buffers, WASM threads, lock-free counters
RPC wrappers (Comlink, Piscina)Same as the clone or transfer underneathSame as underneath; wrap buffers to transferHidden behind promisesA library; Piscina needs Node, Comlink a Worker or portCalling worker functions as if they were async local ones
Main thread and worker: transfer, then shareTransfer moves a buffer back and forth with no copies; a SharedArrayBuffer is posted once and then coordinated with Atomics.
  1. Main thread → Worker threadnew Worker(url, { type: 'module' })A new agent: own realm, heap, globals and event loop. Startup costs milliseconds and memory.
  2. Worker thread → Main threadpostMessage({ type: 'ready' })
  3. Main thread → Worker threadpostMessage({ pixels }, [pixels.buffer])Transfer: no byte copy. On main, pixels.byteLength is now 0 and touching it throws or reads nothing.
  4. Worker thread → Main threadpostMessage(result, [result.buffer])Ownership comes back the same way. Forget the transfer list and you pay a full copy.
  5. Main thread → Worker threadpostMessage(sab), shared, not movedThrows unless the page is crossOriginIsolated. Both threads now map the same memory.
  6. Worker thread → SharedArrayBufferAtomics.store(view, 0, 1)
  7. Worker thread → SharedArrayBufferAtomics.notify(view, 0)
  8. Main thread → SharedArrayBufferAtomics.waitAsync(view, 0, 0)The main thread may not block, so Atomics.wait throws a TypeError there; waitAsync returns a promise.
  9. SharedArrayBuffer → Main threadPromise resolves "ok"
  10. Main thread → Worker threadworker.terminate()Kills the thread immediately; in-flight work and pending messages are dropped.

KEY TERMS

Agents and realms
ECMAScript runs code in agents: one thread with its own execution stack and job queue. A window and each worker are separate agents with separate realms (globals, built-ins). Agents in the same agent cluster can share SharedArrayBuffer memory, but never JavaScript objects.
Structured clone
The algorithm behind postMessage and structuredClone(). It deep-copies plain objects, arrays, Maps, Sets, Dates, typed arrays, Blobs and more, preserves cycles, and throws DataCloneError on functions and DOM nodes. Class instances arrive as plain objects. Cost scales with size and object count and is paid on the sending thread too.
Transferables
ArrayBuffer, MessagePort, OffscreenCanvas, ImageBitmap, ReadableStream/WritableStream/TransformStream, VideoFrame and others. Listing one in the transfer array moves the underlying resource and detaches the original. Typed arrays are not transferable themselves: transfer their .buffer.
SharedArrayBuffer and Atomics
A buffer whose bytes are visible to every agent it is posted to. Plain reads and writes can tear or be reordered; Atomics.add, compareExchange, load and store are indivisible and sequentially consistent. Atomics.wait blocks a worker until notify; compareExchange is the building block for locks and lock-free structures.
Cross-origin isolation
After Spectre, browsers disabled SharedArrayBuffer because it can build a high-resolution timer. It returns only when the document is served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless), so no cross-origin data can end up in the process. Check self.crossOriginIsolated.
Atomics.waitAsync
A non-blocking wait that returns { async, value }: either an immediate "not-equal"/"timed-out" string or a promise that resolves "ok" on notify. It lets the main thread participate in wait/notify protocols. MDN lists it as Baseline 2025 (November 2025).
Pools and RPC
Creating a worker per task is slow; keep a bounded pool sized near the core count. In Node, Piscina (actively maintained, 5.x) provides queueing, backpressure, cancellation and Piscina.move() for transfers. In browsers, Comlink turns postMessage into proxied async calls; it is small and stable but releases rarely.

IN YOUR STACK

JavaScript · Web Worker + transfer list Client

// main.js
const worker = new Worker(new URL("./stats.worker.js", import.meta.url), { type: "module" });
const samples = Float64Array.from({ length: 10_000_000 }, Math.random); // 80 MB

worker.postMessage({ samples }, [samples.buffer]); // move ownership, no copy
console.log(samples.byteLength); // 0: detached on this thread

worker.onmessage = ({ data }) => {
  console.log("mean", data.mean);
  reuse(data.samples); // the same memory, handed back
};
function reuse(buffer) { console.log("got back", buffer.length, "samples"); }

// stats.worker.js
self.onmessage = ({ data: { samples } }) => {
  let total = 0;
  for (const x of samples) total += x;
  self.postMessage({ mean: total / samples.length, samples }, [samples.buffer]);
};
  • Without the second argument the 80 MB would be structured-cloned, blocking the main thread for the serialisation step.
  • Transfer the .buffer, not the typed array. Views over a detached buffer report length 0.
  • structuredClone(value, { transfer: [buf] }) gives the same move semantics within one thread.
Web Worker + transfer list documentation (opens in new tab)

JavaScript · SharedArrayBuffer + Atomics Client

// main.js (the page must be cross-origin isolated)
if (!crossOriginIsolated) throw new Error("SharedArrayBuffer needs COOP + COEP headers");

const counter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
const url = new URL("./count.worker.js", import.meta.url);
const workers = Array.from({ length: 4 }, () => new Worker(url, { type: "module" }));

let finished = 0;
for (const worker of workers) {
  worker.onmessage = () => {
    if (++finished === workers.length) console.log(Atomics.load(counter, 0)); // 4000000
  };
  worker.postMessage(counter); // shared: every worker sees the same memory
}

// count.worker.js
self.onmessage = ({ data: counter }) => {
  for (let i = 0; i < 1_000_000; i++) {
    Atomics.add(counter, 0, 1); // counter[0]++ here would lose updates
  }
  self.postMessage("done");
};
  • counter[0]++ is a read, add and write; two threads interleaving those steps lose increments. Atomics.add makes it one indivisible operation.
  • Contended atomics on one cache line are slow. Prefer per-worker slots summed at the end for real counters.
SharedArrayBuffer + Atomics documentation (opens in new tab)

JavaScript · Atomics.wait / waitAsync Client

// protocol.js: cell[0] = state, cell[1] = payload
export const EMPTY = 0, REQUEST = 1, RESPONSE = 2;
import { EMPTY, REQUEST } from "./protocol.js"; // main.js: one call in flight at a time
const cell = new Int32Array(new SharedArrayBuffer(8));
new Worker(new URL("./square.worker.js", import.meta.url), { type: "module" }).postMessage(cell);
export async function square(n) {
  Atomics.store(cell, 1, n);
  Atomics.store(cell, 0, REQUEST);
  Atomics.notify(cell, 0);                                  // wake the sleeping worker
  const { async, value } = Atomics.waitAsync(cell, 0, REQUEST); // main thread must not block
  if (async) await value; // then the worker has written RESPONSE
  const result = Atomics.load(cell, 1);
  Atomics.store(cell, 0, EMPTY); // free the slot for the next call
  return result;
}
// square.worker.js: dedicated to this loop, so blocking is fine
import { REQUEST, RESPONSE } from "./protocol.js";
self.onmessage = ({ data: cell }) => {
  for (;;) {
    const state = Atomics.load(cell, 0);
    if (state !== REQUEST) { Atomics.wait(cell, 0, state); continue; } // sleep, no spin
    Atomics.store(cell, 1, Atomics.load(cell, 1) ** 2);
    Atomics.store(cell, 0, RESPONSE); Atomics.notify(cell, 0);
  }
};
  • Atomics.wait only sleeps if the slot still holds the expected value, which closes the race between checking and sleeping. It returns "ok" on any notify, so always re-check the state.
  • A worker blocked in Atomics.wait processes no messages or timers. That is the point here, but it means this worker can do nothing else.
  • Atomics.wait throws a TypeError on the browser main thread; use waitAsync there.
Atomics.wait / waitAsync documentation (opens in new tab)

HTTP · Response headers + Express Server

# On the top-level HTML document
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

# Each cross-origin subresource must then use CORS or opt in with
Cross-Origin-Resource-Policy: cross-origin

// Express middleware
app.use((req, res, next) => {
  res.set("Cross-Origin-Opener-Policy", "same-origin");
  res.set("Cross-Origin-Embedder-Policy", "require-corp");
  next();
});

// In the page or a worker
console.log(self.crossOriginIsolated); // true => SharedArrayBuffer is available
  • COOP same-origin cuts the window.opener link to cross-origin popups, which breaks popup-based OAuth and payment flows. Test those first.
  • require-corp blocks no-cors images, scripts and iframes from third parties that do not send CORP or support CORS. COEP credentialless loads them without cookies instead; confirm support in your target browsers.
  • Roll out with Cross-Origin-Embedder-Policy-Report-Only to see what would break before enforcing.
Response headers + Express documentation (opens in new tab)

JavaScript · Node.js + Piscina 5 Server

// server.mjs
import { createServer } from "node:http";
import { availableParallelism } from "node:os";
import { Piscina } from "piscina";
const pool = new Piscina({
  filename: new URL("./fib.worker.mjs", import.meta.url).href,
  maxThreads: availableParallelism(),
  maxQueue: "auto", // maxThreads squared; run() rejects when full
});

createServer(async (req, res) => {
  const n = Number(new URL(req.url, "http://localhost").searchParams.get("n") ?? 30);
  const signal = AbortSignal.timeout(2_000);
  try {
    res.end(JSON.stringify({ result: await pool.run({ n }, { signal }) }));
  } catch {
    res.statusCode = signal.aborted ? 504 : 503; // timed out, queue full, or task threw
    res.end();
  }
}).listen(3000);

// fib.worker.mjs: runs on a pool thread, the event loop stays free
export default function fib({ n }) {
  return n < 2 ? n : fib({ n: n - 1 }) + fib({ n: n - 2 });
}
  • Without a pool, fib(40) on the main thread blocks every other request for seconds; with it, the HTTP loop keeps answering.
  • Arguments and results are structured-cloned; return Piscina.move(buffer) from the worker to transfer instead.
  • Bound the queue. An unbounded queue turns overload into unbounded memory and latency instead of fast 503s.
Node.js + Piscina 5 documentation (opens in new tab)

JavaScript · OffscreenCanvas in a worker Client

// main.js
const canvas = document.querySelector("canvas");
const offscreen = canvas.transferControlToOffscreen(); // the element can no longer be resized here
const worker = new Worker(new URL("./render.worker.js", import.meta.url), { type: "module" });
worker.postMessage({ canvas: offscreen }, [offscreen]);

new ResizeObserver(([entry]) => {
  const { width, height } = entry.contentRect;
  worker.postMessage({ width: width * devicePixelRatio, height: height * devicePixelRatio });
}).observe(canvas);

// render.worker.js: drawing keeps running even when the main thread is busy
let ctx = null;
self.onmessage = ({ data }) => {
  if (data.canvas) ctx = data.canvas.getContext("2d");
  if (data.width && ctx) Object.assign(ctx.canvas, { width: data.width, height: data.height });
};
function frame(time) {
  if (ctx) {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ctx.fillRect((time / 5) % ctx.canvas.width, 20, 40, 40);
  }
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
  • After transferControlToOffscreen, setting width or height on the element throws; resize through the worker as shown.
  • requestAnimationFrame is available in dedicated workers, so animation can run entirely off the main thread.
  • OffscreenCanvas is Baseline widely available (since March 2023); WebGL contexts work the same way via getContext("webgl2").
OffscreenCanvas in a worker documentation (opens in new tab)

WHERE IT BITES

  • Moving work to a worker and then posting a huge object graph every frame: the structured clone on the main thread costs as much as the work you moved. Transfer buffers or keep state in the worker.
  • Using a buffer after transferring it. It is detached: typed array views report length 0 and reading gives nothing useful.
  • Plain reads and writes on SharedArrayBuffer for coordination. Without Atomics you get lost updates and reordering; flags need Atomics.store/load and wait/notify.
  • Turning on COOP/COEP late and discovering it broke OAuth popups, third-party embeds and analytics scripts. Ship report-only first.
  • Spawning a worker per task, or sizing a pool far above the core count. Startup cost and context switching eat the gain.
  • Expecting threads to speed up I/O. Node's docs are explicit that worker_threads help CPU-bound work, not network or disk waits.

CLOSE THE AI. EXPLAIN THIS.

You need to process a 200 MB video frame buffer in a worker 30 times a second. Explain what happens to the bytes with plain postMessage, with a transfer list, and with a SharedArrayBuffer; which one needs extra headers and why; and how the main thread learns that a frame is ready without blocking.

SOURCES

Explainer reviewed