Skip to content
Node.js nd workers 4 min read

Worker Thread Communication

Worker threads run in isolated V8 contexts, so they cannot share JavaScript variables directly the way functions in a single thread can. Instead, Node.js gives you an asynchronous message-passing channel plus a few ways to share raw memory. Understanding when data is copied, when it is transferred, and when it is genuinely shared is the key to writing fast, correct multithreaded Node.js code.

The parent–worker channel

Every Worker instance and its corresponding script are connected by a built-in MessagePort pair. In the parent, you call worker.postMessage() and listen with worker.on('message', ...). Inside the worker, you use the parentPort object exported from node:worker_threads.

// main.mjs
import { Worker } from 'node:worker_threads';

const worker = new Worker(new URL('./worker.mjs', import.meta.url));

worker.on('message', (msg) => {
  console.log('From worker:', msg);
});

worker.postMessage({ type: 'greet', name: 'Ada' });
// worker.mjs
import { parentPort } from 'node:worker_threads';

parentPort.on('message', (msg) => {
  if (msg.type === 'greet') {
    parentPort.postMessage(`Hello, ${msg.name}!`);
  }
});

Output:

From worker: Hello, Ada!

Messages are delivered asynchronously and in order. The channel is fully bidirectional — either side can post at any time.

CommonJS works identically: const { parentPort } = require('node:worker_threads'). Only the module syntax differs; the API is the same.

Passing initial data with workerData

postMessage is great for ongoing communication, but you often need to hand a worker some configuration at startup. Pass it through the workerData option, and read it from the workerData export inside the worker. It is structured-cloned once, when the worker is created.

// main.mjs
import { Worker } from 'node:worker_threads';

const worker = new Worker(new URL('./task.mjs', import.meta.url), {
  workerData: { taskId: 42, rows: [1, 2, 3, 4] },
});
// task.mjs
import { workerData, parentPort } from 'node:worker_threads';

const sum = workerData.rows.reduce((a, b) => a + b, 0);
parentPort.postMessage({ taskId: workerData.taskId, sum });

Because workerData is a one-time snapshot, mutating it inside the worker has no effect on the parent. Use it for inputs, not for live shared state.

Structured clone and its limits

Both postMessage and workerData serialize values using the structured clone algorithm, not JSON.stringify. That means you can pass far more than plain JSON: Map, Set, Date, RegExp, BigInt, typed arrays, ArrayBuffer, and even circular references all survive the trip.

What it cannot clone are things tied to a specific context or that hold external resources:

Value typeClonable?
Plain objects, arraysYes
Map, Set, Date, RegExpYes
Typed arrays, ArrayBufferYes
BigInt, circular refsYes
Functions, classes (as code)No
DOM-like / native handles, socketsNo
WeakMap, WeakSetNo
parentPort.postMessage(() => 42);

Output:

DOMException [DataCloneError]: () => 42 could not be cloned.

The global structuredClone() function uses the exact same algorithm, so it is a handy way to test whether a value will survive postMessage before you send it.

Transferring ArrayBuffers

Cloning a large buffer means copying every byte, which is wasteful. Instead you can transfer ownership: the buffer’s memory is handed to the receiver in O(1), and the sender’s reference becomes detached (zero-length). Pass the buffers as the second transferList argument.

const buf = new Uint8Array(1_000_000);
buf.fill(7);

worker.postMessage({ payload: buf }, [buf.buffer]);

console.log('After transfer, byteLength:', buf.byteLength);

Output:

After transfer, byteLength: 0

For data both threads must read simultaneously, use a SharedArrayBuffer instead — it is genuinely shared memory (no transfer needed), and you coordinate access with the Atomics API. MessagePort instances and FileHandles can also appear in the transfer list.

MessageChannel creates a fresh, standalone pair of connected ports (port1 and port2). This lets you set up communication paths that bypass the main thread entirely — for example, connecting two workers directly, or creating a dedicated channel for one kind of traffic. Ports are transferable, so you send one end to a worker via postMessage.

import { Worker, MessageChannel } from 'node:worker_threads';

const { port1, port2 } = new MessageChannel();
const worker = new Worker(new URL('./worker.mjs', import.meta.url));

// Hand port2 to the worker; keep port1 on the main side.
worker.postMessage({ port: port2 }, [port2]);

port1.on('message', (msg) => console.log('Direct channel:', msg));
port1.postMessage('ping');
// worker.mjs
import { parentPort } from 'node:worker_threads';

parentPort.once('message', ({ port }) => {
  port.on('message', (msg) => {
    if (msg === 'ping') port.postMessage('pong');
  });
});

Output:

Direct channel: pong

Best Practices

  • Use workerData for one-time startup inputs and postMessage for ongoing, two-way communication.
  • Validate or tag messages (e.g. a type field) so each side can route them; raw payloads get unmanageable fast.
  • Transfer large ArrayBuffers instead of cloning them, and remember the sender’s view is detached afterward.
  • Reach for SharedArrayBuffer + Atomics only when threads truly need concurrent access to the same memory.
  • Test serializability with structuredClone(value) before sending — it fails the same way postMessage does.
  • Never try to send functions, class instances, or open handles; pass plain data and reconstruct behavior in the worker.
  • Use a MessageChannel to wire workers together directly and keep the main thread off the hot path.
Last updated June 14, 2026
Was this helpful?