Worker Threads

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example was run on all three lines unless the text says otherwise. The node:worker_threads module runs additional JavaScript threads inside one Node.js process. Each worker has its own V8 isolate, its own event loop and its own heap, so a loop that takes two seconds in a worker does not delay a single timer or HTTP response on the main thread. The module is Stability 2 (Stable); a few of its newer corners are not: postMessageToThread() is 1.1 (Active development), worker_threads.locks is 1 (Experimental), the --allow-worker permission flag is 1.1, and the browser-style Worker global added in Node.js 26.9 is 1 (Experimental) behind a flag. Workers are for CPU-bound JavaScript; for I/O, the built-in asynchronous APIs are more efficient than any worker. The page builds a worker pool for the Bookshelf application that grows across this section (see Introduction & Architecture), hashes cover images with it, and ends with a proof-of-work miner. The same ideas in the browser are in Web Workers.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

node:worker_threads, Worker, isMainThread, parentPort, workerData, threadId

2 Stable

Added in v10.5.0. No longer behind --experimental-worker since v11.7.0, no longer experimental since v12.11.0.

MessageChannel, MessagePort, receiveMessageOnPort()

Stable

receiveMessageOnPort() added in v12.3.0; since v15.12.0 it also accepts a BroadcastChannel.

BroadcastChannel

Stable

Added in v15.4.0, no longer experimental in v18.0.0.

setEnvironmentData(), getEnvironmentData(), SHARE_ENV

Stable

Environment data no longer experimental in v17.5.0 and v16.15.0; SHARE_ENV added in v11.14.0.

resourceLimits option, worker.resourceLimits

Stable

Added in v13.2.0 and v12.16.0.

markAsUntransferable(), markAsUncloneable()

Stable

markAsUntransferable() added in v14.5.0 and v12.19.0; markAsUncloneable() in v23.0.0 and v22.10.0.

worker.getHeapStatistics(), worker.cpuUsage(), worker.threadName

Stable

getHeapStatistics() added in v24.0.0 and v22.16.0; cpuUsage() in v24.6.0 and v22.19.0; threadName and the name option semantics in v24.6.0 and v22.20.0 (the name option itself since v19.8.0).

worker.startCpuProfile(), worker.startHeapProfile()

Stable

startCpuProfile() added in v24.8.0; startHeapProfile() in v24.9.0 and v22.20.0.

worker[Symbol.asyncDispose]()

Stable

Added in v24.2.0 and v22.18.0. The await using syntax needs a V8 that supports it, which Node.js 22 does not (see the dispose example).

worker_threads.postMessageToThread(), process.on('workerMessage')

1.1 Active development

Added in v22.5.0 and v20.19.0.

worker_threads.locks (LockManager)

1 Experimental

Added in v24.5.0; absent on Node.js 22 (the named import fails).

Transferring a TCP net.Server or net.Socket in a transferList

Documented in the Node.js 26 and 24 docs

The Node.js 22 documentation does not list them and the run below fails with a DataCloneError. net.BoundSocket is documented only in the latest docs and was not run here.

Global Worker (web-style), --experimental-web-worker

1 Experimental

Added in v26.9.0. The flag is unknown on Node.js 24 and 22.

--allow-worker (permission model)

1.1 Active development

Added in v20.0.0. With --permission a worker cannot be created without it.

Why threads, and when not

The main thread of a Node.js process runs your JavaScript, one callback at a time. Asynchronous I/O is cheap because libuv and the operating system do the waiting; but a long synchronous computation (hashing, parsing a large file, image work, compression in userland JavaScript) occupies the only thread and nothing else runs until it returns. The program below measures that. It runs the same one-million-step hash chain on the main thread and then in a worker, while a 10 ms interval records the longest gap between ticks.

Event-loop gap with the work on the main thread versus in a worker
import { Worker, isMainThread, parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';

const work = () => {
  let h = Buffer.from('seed');
  for (let i = 0; i < 1_000_000; i++) h = createHash('sha256').update(h).digest();
  return h.toString('hex').slice(0, 8);
};

if (!isMainThread) {
  parentPort.postMessage(work());
} else {
  async function measure(label, fn) {
    let maxGap = 0, last = performance.now();
    const ticker = setInterval(() => {
      const now = performance.now();
      maxGap = Math.max(maxGap, now - last);
      last = now;
    }, 10);
    const start = performance.now();
    const result = await fn();
    const ms = Math.round(performance.now() - start);
    await new Promise((r) => setTimeout(r, 30));          // let a late tick report the gap
    clearInterval(ticker);
    console.log(label, { result, ms, maxTickGapMs: Math.round(maxGap) });
  }
  await measure('on the main thread', async () => { await new Promise((r) => setImmediate(r)); return work(); });
  await measure('in a worker       ', () => new Promise((resolve, reject) => {
    const w = new Worker(new URL(import.meta.url));
    w.once('message', resolve);
    w.once('error', reject);
  }));
}
Observed output (Node.js 26.10.0, macOS arm64; timings vary run to run)
on the main thread { result: '6871b211', ms: 474, maxTickGapMs: 474 }
in a worker        { result: '6871b211', ms: 402, maxTickGapMs: 11 }

The result is identical; what changed is the longest tick gap, 474 ms (the whole computation) against 11 ms (the 10 ms timer plus scheduling noise). The same shape appeared on Node.js 24 (462 ms against 11 ms) and 22 (497 ms against 11 ms). Total elapsed time is not the point: one worker does not make a single computation faster, it keeps the event loop free. To make one computation faster you split it across several workers, as the pool and proof-of-work sections below do.

Threads are not the only way to use more than one core, and they are not always the right one:

Table 2. Choosing a concurrency tool in Node.js
Tool Use it for Cost and limits

Asynchronous APIs (fs, net, fetch, timers)

Anything that waits: files, sockets, databases, HTTP

No extra threads of yours. Some operations run on libuv’s thread pool (default 4 threads, UV_THREADPOOL_SIZE), which is separate from worker threads.

node:worker_threads

CPU-bound JavaScript that must stay in the same process, optionally sharing memory

One V8 isolate and event loop per worker. Messages are cloned (or transferred). A crash in a worker that exhausts memory can still bring down the process.

node:child_process

Other programs, or isolation from your own code: see Child Processes

A full process per child, IPC by serialisation, no shared memory.

node:cluster

Several copies of a server sharing one listening port: see Cluster & Scaling

Full processes; state is not shared.

Several containers or machines

Horizontal scaling beyond one host

Operational complexity; see Concurrency Models for the comparison.

How blocking hurts a real server, and the partitioning alternatives that need no thread at all, are the subject of Don’t Block the Event Loop and The Event Loop.

Creating a worker: Worker, isMainThread, parentPort, workerData

A Worker runs one script. The same file can be both the parent and the worker: isMainThread is true in the main thread and false inside a worker, workerData is a clone of whatever the constructor was given, and parentPort is the channel back to the parent (it is null on the main thread). Worker scripts are ECMAScript modules or CommonJS according to the same rules as any other file, so new URL(import.meta.url) is the ESM way to point at yourself and __filename the CommonJS one.

One file that is both the parent and the worker
import { Worker, isMainThread, parentPort, workerData, threadId } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), { workerData: { title: 'Dune', n: 35 } });
  worker.once('online', () => console.log('main: worker', worker.threadId, 'is online'));
  worker.on('message', (msg) => console.log('main: got', msg));
  worker.once('error', (err) => console.error('main: worker threw', err.message));
  worker.once('exit', (code) => console.log('main: worker exited with', code));
} else {
  const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
  parentPort.postMessage({ from: threadId, title: workerData.title, fib: fib(workerData.n) });
}
Output (identical on Node.js 26, 24 and 22)
main: worker 1 is online
main: got { from: 1, title: 'Dune', fib: 9227465 }
main: worker exited with 0

The CommonJS equivalent differs only in the module boilerplate:

The same pattern in CommonJS
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename, { workerData: [1, 2, 3] });
  worker.once('message', (sum) => console.log('sum from worker:', sum));
} else {
  parentPort.postMessage(workerData.reduce((a, b) => a + b, 0));
}

The constructor accepts these arguments and options; the Node.js docs list more, these are the ones you use daily:

Table 3. new Worker(filename[, options])
Argument or option What it does

filename

A path or a file: / data: URL. A string path must be absolute or start with ./ or ../ (relative to the current working directory, not to the calling file). With eval: true it is source code instead of a path. A data: URL is interpreted by the ES module loader.

workerData

Any structured-cloneable value, cloned into the worker. A value that cannot be cloned, such as an object holding a function, makes the constructor throw. MessagePort-like objects inside it need a matching transferList option.

argv

Extra values appended to process.argv inside the worker, as if they were command-line arguments.

env

Initial process.env of the worker. The default is a copy of the parent’s environment; SHARE_ENV shares it (see below).

execArgv

Node.js command-line options for the worker. By default the worker inherits the parent’s options. V8 options such as --max-old-space-size and process-level options such as --title are not supported here.

stdout, stderr, stdin

With stdout: true or stderr: true the worker’s output is not piped to the parent’s streams and is available as worker.stdout and worker.stderr; with stdin: true, worker.stdin feeds the worker’s process.stdin.

resourceLimits

Heap, young generation, code range and stack limits for the worker (see the resource-limits section).

name

A label used in the thread name and worker title for debugging; the final title is [worker id] name. The maximum length depends on the operating system (16 characters on Linux, 64 on macOS, 32,767 on Windows), longer values are truncated.

trackUnmanagedFds

Defaults to true since v14.9.0: file descriptors opened with fs.open() in the worker are closed when it exits.

transferList

Objects (for example a MessagePort placed inside workerData) that are transferred rather than cloned.

Events and exit codes

A worker is an EventEmitter and reports its life cycle through events. Wrapping them in a promise is the usual way to consume a one-shot worker. Three events must all be handled, because a failure shows up differently depending on how the worker dies:

Table 4. Worker events
Event When it fires

'online'

The worker started executing JavaScript.

'message'

The worker called parentPort.postMessage(); the listener gets a clone of the value.

'messageerror'

A message could not be deserialised on the receiving side.

'error'

The worker threw an uncaught exception; the worker is terminated and the listener gets the error (the same class is preserved, as the next example shows).

'exit'

Always the last event. The code is the one passed to process.exit() in the worker (which ends only that thread), 0 for a normal end, and 1 if the worker was terminated.

Wrapping a worker in a promise
import { Worker } from 'node:worker_threads';

export function runInWorker(file, workerData) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(file, { workerData });
    worker.once('message', resolve);                     // first result wins
    worker.once('error', reject);                        // uncaught exception in the worker
    worker.once('exit', (code) => {
      if (code !== 0) reject(new Error(`worker stopped with exit code ${code}`));
    });
  });
}

// worker body inline via a data: URL so the example is a single file
const src = `
  import { parentPort, workerData } from 'node:worker_threads';
  if (workerData.fail) throw new RangeError('boom from the worker');
  parentPort.postMessage(workerData.x * 2);
`;
const url = new URL('data:text/javascript,' + encodeURIComponent(src));
console.log('ok   ->', await runInWorker(url, { x: 21 }));
console.log('fail ->', await runInWorker(url, { fail: true }).catch((e) => `${e.name}: ${e.message}`));
Output (identical on Node.js 26, 24 and 22)
ok   -> 42
fail -> RangeError: boom from the worker

A worker that has registered a listener on parentPort, or has an open handle, stays alive until you stop it, and it keeps the parent process alive too. worker.terminate() stops it, worker.unref() lets the process exit regardless, and parentPort.close() (or closing the last port) lets a worker end by itself. Those are covered in the section on stopping workers.

What a worker is, and how it differs from the main thread

Most of the Node.js API works inside a worker; the notable differences are that process.exit() stops only the current thread, process.abort() and process.chdir() and the user-id setters are not available, process.title cannot be changed, signals are not delivered to process.on(…​) handlers of a worker, there is no IPC channel from a parent process, and the trace_events module is not supported. process.env is a copy by default. Native add-ons can be loaded from several threads only if they are written for it. Workers can create workers.

The following example creates three workers with the three environment modes and passes the same data in each of the ways Node.js offers: workerData (one clone per worker), setEnvironmentData() (a value set once and visible to every worker created afterwards) and argv.

Environment, environment data, argv and thread names
import { Worker, isMainThread, parentPort, workerData, threadId, threadName, setEnvironmentData, getEnvironmentData, SHARE_ENV } from 'node:worker_threads';

if (isMainThread) {
  process.env.BOOKSHELF_MODE = 'main-value';
  setEnvironmentData('locale', 'es-ES');                    // cloned into every Worker created afterwards
  const results = [];
  for (const options of [
    { name: 'copy-env' },                                  // default: process.env is copied
    { name: 'share-env', env: SHARE_ENV },                 // same object, read and write
    { name: 'custom-env', env: { ONLY_THIS: '1' } },
  ]) {
    await new Promise((resolve) => {
      const w = new Worker(new URL(import.meta.url), { ...options, argv: ['--verbose'], workerData: options.name });
      w.on('message', (m) => results.push(m));
      w.once('exit', resolve);
    });
    console.log(options.name, '-> main sees WORKER_WROTE =', process.env.WORKER_WROTE);
    delete process.env.WORKER_WROTE;
  }
  console.table(results);
} else {
  process.env.WORKER_WROTE = 'from-worker';
  parentPort.postMessage({
    name: workerData, threadId, threadName,
    modeVisible: process.env.BOOKSHELF_MODE ?? '(unset)',
    onlyThis: process.env.ONLY_THIS ?? '(unset)',
    locale: getEnvironmentData('locale'),
    argv: process.argv.slice(2).join(' '),
  });
}
Output (identical on Node.js 26, 24 and 22)
copy-env -> main sees WORKER_WROTE = undefined
share-env -> main sees WORKER_WROTE = from-worker
custom-env -> main sees WORKER_WROTE = undefined
┌─────────┬──────────────┬──────────┬──────────────┬──────────────┬───────────┬─────────┬─────────────┐
│ (index) │ name         │ threadId │ threadName   │ modeVisible  │ onlyThis  │ locale  │ argv        │
├─────────┼──────────────┼──────────┼──────────────┼──────────────┼───────────┼─────────┼─────────────┤
│ 0       │ 'copy-env'   │ 1        │ 'copy-env'   │ 'main-value' │ '(unset)' │ 'es-ES' │ '--verbose' │
│ 1       │ 'share-env'  │ 2        │ 'share-env'  │ 'main-value' │ '(unset)' │ 'es-ES' │ '--verbose' │
│ 2       │ 'custom-env' │ 3        │ 'custom-env' │ '(unset)'    │ '1'       │ 'es-ES' │ '--verbose' │
└─────────┴──────────────┴──────────┴──────────────┴──────────────┴───────────┴─────────┴─────────────┘

threadId is unique per Worker instance within the process (the main thread is 0), so it is the address you use with postMessageToThread(). On the main thread parentPort is null and resourceLimits is an empty object.

Output from a worker travels through the parent’s event loop as messages. By default it is forwarded to the parent’s process.stdout and process.stderr; with stdout: true and stderr: true you capture it instead:

Capturing a worker’s standard streams
import { Worker } from 'node:worker_threads';

const w = new Worker('console.log("from worker"); console.error("worker stderr")', { eval: true, stdout: true, stderr: true });
let captured = '';
w.stdout.on('data', (d) => { captured += `[out] ${d}`; });
w.stderr.on('data', (d) => { captured += `[err] ${d}`; });
w.once('exit', () => console.log(captured.trim()));
Output (Node.js 26 and 24)
[out] from worker
[err] worker stderr

Because stdio is message-based, a worker’s console.log() can be held back while the main thread is stuck in a synchronous loop, so a silent worker next to a busy main thread is not necessarily a hung worker (documented under "Synchronous blocking of stdio").

Execution arguments and preload scripts

Unless execArgv is set, a worker inherits the command-line options of the process, including --require and --import preloads. A preload that starts a worker unconditionally therefore starts a worker in each worker, until the process crashes. Guard such code with isMainThread or pass an explicit execArgv.

TypeScript workers

Node.js strips types natively (see TypeScript in Node.js), and that applies to worker files as well, so a worker can be a .ts file with no build step. The extension must be written in full.

A worker written in TypeScript
import { Worker } from 'node:worker_threads';

interface Job { n: number }
const worker = new Worker(new URL('./ts-worker.ts', import.meta.url), { workerData: { n: 10 } satisfies Job });
worker.on('message', (value: number) => console.log('square from a .ts worker:', value));
The worker file
import { parentPort, workerData } from 'node:worker_threads';

const { n } = workerData as { n: number };
parentPort!.postMessage(n * n);
Output (identical on Node.js 26, 24 and 22)
square from a .ts worker: 100

The permission model

When the process runs with --permission, creating a worker throws ERR_ACCESS_DENIED unless --allow-worker was given. The flag is Stability 1.1 and Node.js prints a security warning (PERM0006) when it is used, saying it must be used with extreme caution because it could invalidate the permission model; the model as a whole is described in Permission Model.

Running the worker-creating script under the permission model
import { Worker } from 'node:worker_threads';
try { new Worker('1', { eval: true }); console.log('worker started'); }
catch (e) { console.log(e.code, '-', e.message); }
node --permission --allow-fs-read='*' perm.js                  # ERR_ACCESS_DENIED
node --permission --allow-fs-read='*' --allow-worker perm.js   # worker started (plus a SecurityWarning, PERM0006)
Observed output on Node.js 26
ERR_ACCESS_DENIED - Access to this API has been restricted. Use --allow-worker to manage permissions.
worker started

Messaging: MessageChannel, MessagePort and structured clone

Every worker has a built-in pair of connected MessagePort objects: worker.postMessage() and the worker’s parentPort.postMessage(). Messages are copied using the HTML structured clone algorithm, not JSON. That brings circular references, Date, Map, Set, RegExp, BigInt, typed arrays and WebAssembly.Module instances across the thread boundary, and it excludes functions and most native-backed objects. Instead of mutable sharing, you get copies: changing a message after posting it has no effect on what the other side received.

What survives structured clone, and what does not
import { MessageChannel } from 'node:worker_threads';

const { port1, port2 } = new MessageChannel();
port1.on('message', (m) => { console.log('received', m); });
port1.on('messageerror', (e) => console.log('messageerror', e.message));

const circular = { name: 'loop' }; circular.self = circular;
port2.postMessage({ when: new Date(0), tags: new Set(['a']), map: new Map([[1, 'x']]), big: 10n, circular, re: /x/g });

try { port2.postMessage({ fn() {} }); } catch (e) { console.log(e.name, '-', e.message); }
class Book { constructor(t) { this.title = t; } get upper() { return this.title.toUpperCase(); } }
port2.postMessage(new Book('Dune'));               // arrives as a plain object

const bytes = new Uint8Array([1, 2, 3, 4]);
port2.postMessage(bytes);                           // copy
console.log('after copy    :', bytes.byteLength);
port2.postMessage(bytes, [bytes.buffer]);           // transfer
console.log('after transfer:', bytes.byteLength, bytes.buffer.detached);
setTimeout(() => port1.close(), 50);
Observed output (identical on Node.js 26, 24 and 22)
DataCloneError - fn() {} could not be cloned.
after copy    : 4
after transfer: 0 true
received {
  when: 1970-01-01T00:00:00.000Z,
  tags: Set(1) { 'a' },
  map: Map(1) { 1 => 'x' },
  big: 10n,
  circular: <ref *1> { name: 'loop', self: [Circular *1] },
  re: /x/g
}
received { title: 'Dune' }
received Uint8Array(4) [ 1, 2, 3, 4 ]
received Uint8Array(4) [ 1, 2, 3, 4 ]

Three details are visible in that output. A function inside a message throws a DataCloneError at the sender. A class instance arrives as a plain object: prototypes, getters and private state are not cloned (the upper getter of Book is gone), which is why code written around classes should send plain data and rebuild the objects on the other side. And bytes.byteLength becomes 0 after the transfer: ownership of the memory moved, so the sender’s view is detached.

The same Error class does cross the boundary in an 'error' event (as the promise example showed with RangeError), but errors posted as ordinary messages follow the structured-clone rules, which is why the pool below sends an error as a plain description.

Table 5. Cloning pitfalls
Pitfall What to do

Posting a function, a class instance with behaviour, or a framework object (a request, a socket wrapper, a database client)

Send plain data. In the worker, rebuild what you need. Functions throw DataCloneError; class instances silently lose their prototype.

Cloning a very large object for every task

Cloning is a copy, proportional to the size. Transfer buffers (next section) or share memory.

Treating a message as a reference to the parent’s object

It is a copy; mutations never propagate. Send results back explicitly.

Using JSON.stringify yourself before postMessage()

Needless: structured clone is faster for typed arrays and keeps Map, Set, Date and BigInt.

Dedicated channels: MessageChannel

The built-in port is a single global channel per worker. The Node.js docs encourage creating a MessageChannel (a pair port1, port2) for each concern and handing one port to the other thread through the existing channel, listing it in the transfer list. A port can also be handed between workers, so two workers can talk directly without going through the parent.

Handing one end of a MessageChannel to a worker
import { Worker, MessageChannel, isMainThread, parentPort, threadId } from 'node:worker_threads';

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url));
  const { port1, port2 } = new MessageChannel();
  worker.postMessage({ hello: port2 }, [port2]);          // hand one end to the worker
  port1.on('message', (m) => {
    console.log('main via port1:', m);
    port1.close();                                         // closing one end closes both
  });
  port1.postMessage('ping');
  worker.once('exit', () => console.log('worker exited'));
} else {
  parentPort.once('message', ({ hello }) => {
    hello.on('message', (m) => hello.postMessage(`${m} -> pong from thread ${threadId}`));
    hello.on('close', () => parentPort.close());           // nothing left to listen to: let the worker end
  });
}
Output (identical on Node.js 26, 24 and 22)
main via port1: ping -> pong from thread 1
worker exited

Closing either end of a channel closes both (each side gets a 'close' event). A port with a 'message' listener keeps its thread alive, which is why the worker above also closes parentPort when the channel closes. port.ref() and port.unref() control that behaviour, and port.start() exists for parity with the Web MessagePort; attaching a 'message' listener starts the port automatically.

Reading messages synchronously: receiveMessageOnPort()

receiveMessageOnPort(port) takes the oldest queued message out of a port without waiting for an event loop turn, returning { message } or undefined. It suits worker code that wants a message at a specific point in an otherwise synchronous routine.

Receiving without an event
import { MessageChannel, receiveMessageOnPort } from 'node:worker_threads';

const { port1, port2 } = new MessageChannel();
port1.postMessage({ n: 1 });
port1.postMessage({ n: 2 });
// synchronous: no event-loop turn, no 'message' listener, messages come out in order
console.log(receiveMessageOnPort(port2));   // { message: { n: 1 } }
console.log(receiveMessageOnPort(port2));   // { message: { n: 2 } }
console.log(receiveMessageOnPort(port2));   // undefined
port1.close();
Output (identical on Node.js 26, 24 and 22)
{ message: { n: 1 } }
{ message: { n: 2 } }
undefined

One-to-many: BroadcastChannel

A BroadcastChannel connects every BroadcastChannel created with the same name, in any thread of the process. A message goes to all other subscribers, never back to the sender. It is a good fit for invalidation messages (a cache entry changed) and a poor one for work distribution, because every subscriber receives every message.

Fan-out to two workers
import { Worker, BroadcastChannel, isMainThread, workerData } from 'node:worker_threads';

if (isMainThread) {
  const bc = new BroadcastChannel('catalog-events');
  const workers = [1, 2].map((id) => new Worker(new URL(import.meta.url), { workerData: { id } }));
  await Promise.all(workers.map((w) => new Promise((r) => w.once('message', r))));   // wait until both subscribed
  bc.postMessage({ type: 'book-added', isbn: '978-0-00-000001' });                    // fan-out to every other subscriber
  await Promise.all(workers.map((w) => new Promise((r) => w.once('exit', r))));
  bc.close();
} else {
  const bc = new BroadcastChannel('catalog-events');
  bc.onmessage = ({ data }) => { console.log(`worker ${workerData.id} received`, data); bc.close(); };
  (await import('node:worker_threads')).parentPort.postMessage('ready');
}
Observed output (the order of the two lines is not defined; identical content on Node.js 26, 24 and 22)
worker 1 received { type: 'book-added', isbn: '978-0-00-000001' }
worker 2 received { type: 'book-added', isbn: '978-0-00-000001' }

Thread to thread without a parent: postMessageToThread()

postMessageToThread(threadId, value[, transferList][, timeout]) (Stability 1.1) sends a value to any thread by its threadId, including the main thread and siblings, and returns a promise that settles when the target has handled it. The target receives it as a workerMessage event on process. If the target has no listener, or the listener throws, the promise rejects (ERR_WORKER_MESSAGING_FAILED, ERR_WORKER_MESSAGING_ERRORED); the timeout option bounds the wait (ERR_WORKER_MESSAGING_TIMEOUT), and sending to yourself throws ERR_WORKER_MESSAGING_SAME_THREAD.

Sending to a thread by its id
import { Worker, isMainThread, postMessageToThread, threadId } from 'node:worker_threads';

if (isMainThread) {
  const w = new Worker(new URL(import.meta.url));
  await new Promise((r) => w.once('online', r));
  await new Promise((r) => setTimeout(r, 100));                    // give the worker time to register its listener
  await postMessageToThread(w.threadId, { hello: 'from thread 0' }, 1000);
  console.log('delivered and handled');
  w.terminate();
} else {
  process.on('workerMessage', (value, source) => console.log(`thread ${threadId} got`, value, 'from thread', source));
  setInterval(() => {}, 1000);
}
Observed output (identical on Node.js 26, 24 and 22, with no experimental warning in these runs)
thread 1 got { hello: 'from thread 0' } from thread 0
delivered and handled

Transferables

Cloning copies bytes; transferring moves them. A transferList accepts ArrayBuffer, MessagePort, FileHandle, and in the Node.js 24 and 26 docs also a TCP net.Server or net.Socket (26 adds net.BoundSocket). After the transfer the object is unusable on the sending side. Passing an ArrayBuffer in the list is how you avoid copying an image or a chunk of a file: the receiver gets the same memory, the sender gets a detached, zero-length buffer, as the clone example showed.

Two rules avoid surprises. All typed arrays and Buffer objects are views of an ArrayBuffer, and transferring that buffer detaches every view over it. And Buffer.from() and Buffer.allocUnsafe() for small sizes draw from a shared pool, so their .buffer is a large ArrayBuffer shared with unrelated buffers; Node.js marks the pool with markAsUntransferable(), and trying to transfer it throws. Copy into a fresh buffer first (new Uint8Array(source)), as the pool below does. markAsUntransferable() and markAsUncloneable() let you apply the same protection to your own objects.

Untransferable and uncloneable objects
import { MessageChannel, markAsUntransferable, markAsUncloneable } from 'node:worker_threads';
const { port1, port2 } = new MessageChannel();
const pooled = new ArrayBuffer(16);
markAsUntransferable(pooled);
try { port1.postMessage(pooled, [pooled]); } catch (e) { console.log(e.name, e.code); }
const secret = { token: 'placeholder' };
markAsUncloneable(secret);
try { port1.postMessage(secret); } catch (e) { console.log(e.name, e.code ?? ''); }
const b = Buffer.from('abc'); // small Buffers share a pooled ArrayBuffer
console.log('Buffer.from("abc").buffer.byteLength =', b.buffer.byteLength);
try { port1.postMessage(b, [b.buffer]); } catch (e) { console.log('pooled Buffer:', e.name); }
port1.close();
Observed output on Node.js 26 (the same on 24 and 22 apart from the size of the pooled buffer)
DataCloneError 25
DataCloneError 25
Buffer.from("abc").buffer.byteLength = 65600
pooled Buffer: DataCloneError

Moving a listening TCP server into a worker lets a worker accept connections itself. Read the port before transferring, because the handle is gone from the sender afterwards.

Transferring a listening net.Server to a worker
import net from 'node:net';
import { Worker, isMainThread, parentPort, threadId } from 'node:worker_threads';

if (isMainThread) {
  const server = net.createServer();
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address();                         // read it before the handle moves away
  const worker = new Worker(new URL(import.meta.url));
  worker.postMessage({ server }, [server]);                  // the listening socket now belongs to the worker
  await new Promise((resolve) => worker.once('message', resolve));
  const client = net.connect(port, '127.0.0.1');
  client.setEncoding('utf8');
  client.on('data', (text) => console.log('client received:', text.trim()));
  client.on('close', () => worker.terminate());
} else {
  parentPort.once('message', ({ server }) => {
    server.on('connection', (socket) => socket.end(`served by thread ${threadId}\n`));
    parentPort.postMessage('listening');
  });
}
Observed output on Node.js 26 and 24
client received: served by thread 1

On Node.js 22.23.3 the same program fails with a DataCloneError (Found invalid value in transferList), consistent with the 22 documentation. Transferring a connected net.Socket has stricter conditions (a freshly accepted TCP connection that has not started reading and has no buffered data), otherwise ERR_WORKER_HANDLE_NOT_TRANSFERABLE is thrown. For general HTTP serving across cores, Cluster & Scaling is still the established tool.

Shared memory: SharedArrayBuffer and Atomics

Where the pool above moves buffers, a SharedArrayBuffer is visible to every thread that receives it: it cannot be in a transfer list, it is simply posted (cloned as a handle to the same memory) in a message or in workerData. This is the only way worker threads share mutable memory, and it brings the usual shared-memory problem: unsynchronised read-modify-write sequences race. Atomics provides indivisible operations (add, load, store, compareExchange, exchange) and a wait/notify mechanism on an Int32Array (or BigInt64Array) over the shared buffer. In Node.js you do not need the cross-origin isolation headers that browsers require to expose SharedArrayBuffer.

The first example makes four workers increment one counter 200,000 times each, once with a plain counter[0]++ and once with Atomics.add().

A data race, and the fix with Atomics
import { Worker, isMainThread, workerData } from 'node:worker_threads';

const N = 200_000, THREADS = 4;
if (isMainThread) {
  for (const mode of ['plain', 'atomic']) {
    const sab = new SharedArrayBuffer(4);
    const counter = new Int32Array(sab);
    await Promise.all(Array.from({ length: THREADS }, () => new Promise((res, rej) => {
      const w = new Worker(new URL(import.meta.url), { workerData: { sab, mode } });
      w.once('exit', res); w.once('error', rej);
    })));
    console.log(mode.padEnd(6), 'expected', N * THREADS, 'got', counter[0]);
  }
} else {
  const counter = new Int32Array(workerData.sab);
  for (let i = 0; i < N; i++) {
    if (workerData.mode === 'plain') counter[0]++;      // read-modify-write: a data race
    else Atomics.add(counter, 0, 1);                    // one indivisible step
  }
}
Observed output (the plain total changes on every run; Node.js 26, 24 and 22 all lost updates)
plain  expected 800000 got 338238
atomic expected 800000 got 800000

Waiting for another thread is the other half. Atomics.wait() blocks the calling thread until another thread calls Atomics.notify() on the same index (or a timeout), which is acceptable inside a worker and wrong on the main thread, where it would freeze the event loop. Atomics.waitAsync() returns a promise instead and is safe on the main thread.

Atomics.waitAsync on the main thread, Atomics.notify from a worker
import { Worker, isMainThread, workerData } from 'node:worker_threads';

if (isMainThread) {
  const flag = new Int32Array(new SharedArrayBuffer(8));   // [0]=state, [1]=result
  const w = new Worker(new URL(import.meta.url), { workerData: { buf: flag.buffer } });
  // asynchronous wait: allowed on the main thread
  const { async, value } = Atomics.waitAsync(flag, 0, 0, 5000);
  console.log('main: waiting, async =', async);
  console.log('main: woke with', await value, 'result =', Atomics.load(flag, 1));
  await new Promise((r) => w.once('exit', r));
} else {
  const flag = new Int32Array(workerData.buf);
  Atomics.store(flag, 1, 42);
  Atomics.store(flag, 0, 1);
  Atomics.notify(flag, 0);                                   // wake one waiter
  // blocking wait is fine inside a worker (never on the main thread)
  console.log('worker: Atomics.wait ->', Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50));
}
Output (identical on Node.js 26, 24 and 22)
main: waiting, async = true
main: woke with ok result = 42
worker: Atomics.wait -> timed-out

Guidelines that keep shared memory manageable:

  • Prefer messages and transfers; reach for SharedArrayBuffer only for large data that several threads must read, or for a tiny control block such as a stop flag or a counter, as in the proof-of-work example.

  • Share typed arrays of fixed layout. Objects, strings and Map cannot live in shared memory.

  • Use Atomics for every access to a location that another thread may write. Plain reads can be reordered or cached.

  • Never use Atomics.wait() on the main thread.

  • Without a lock, compound updates over several locations are still unsafe. worker_threads.locks (Stability 1, Experimental, added in v24.5.0) mirrors the browser Web Locks API for coordinating access to a named resource across threads; it works on Node.js 26 and 24 and does not exist on 22.

A named lock across threads (Experimental)
import { locks } from 'node:worker_threads';
await locks.request('bookshelf-index', { mode: 'exclusive' }, async (lock) => {
  console.log('holding', lock.name, lock.mode);
});
console.log(JSON.stringify(await locks.query()));
Output on Node.js 26 and 24
holding bookshelf-index exclusive
{"held":[],"pending":[]}

Resource limits, stopping workers, and failure

A worker shares the process with the main thread, so it needs fences. The resourceLimits option constrains the worker’s JavaScript engine: maxOldGenerationSizeMb, maxYoungGenerationSizeMb, codeRangeSizeMb and stackSizeMb (default 4). Reaching a limit terminates only the worker. The limits apply to the JS heap only: ArrayBuffer memory and other external memory are not counted, and a process-wide out-of-memory can still abort the whole process. The command-line options --max-old-space-size and --max-semi-space-size override the matching options.

A worker that runs out of heap
import { Worker } from 'node:worker_threads';

const w = new Worker(`
  const keep = [];
  for (;;) keep.push(new Array(1e5).fill('x'.repeat(10)));
`, { eval: true, resourceLimits: { maxOldGenerationSizeMb: 16, maxYoungGenerationSizeMb: 4 } });

console.log('limits seen by parent:', w.resourceLimits);
w.on('error', (e) => console.log('error event:', e.code, '-', e.message));
w.on('exit', (code) => console.log('exit code:', code));
Output (identical on Node.js 26, 24 and 22)
limits seen by parent: {
  maxYoungGenerationSizeMb: 4,
  maxOldGenerationSizeMb: 16,
  codeRangeSizeMb: -1,
  stackSizeMb: 4
}
error event: ERR_WORKER_OUT_OF_MEMORY - Worker terminated due to reaching memory limit: JS heap out of memory
exit code: 1

The main thread keeps running, receives an 'error' event with the code ERR_WORKER_OUT_OF_MEMORY, and then 'exit'. A pool should treat this like any crash: reject the job and start a replacement.

terminate, unref and dispose

worker.terminate() stops the thread as soon as possible, even in a tight loop that never yields, and returns a promise for the exit code (since v12.5.0 it is asynchronous; passing a callback is deprecated). A worker terminated this way exits with code 1.

Stopping a worker that never yields
import { Worker } from 'node:worker_threads';

const w = new Worker('for (;;) {}', { eval: true });       // a worker that never yields
w.once('exit', (code) => console.log('exit code after terminate():', code));
await new Promise((r) => w.once('online', r));
const code = await w.terminate();                          // stops even a tight loop
console.log('terminate() resolved with', code);
Output (identical on Node.js 26, 24 and 22)
exit code after terminate(): 1
terminate() resolved with 1

worker.unref() makes the worker not keep the process alive, worker.ref() undoes that. worker[Symbol.asyncDispose]() calls terminate() when a scope ends, which pairs with the await using declaration:

Scoped termination with await using
import { Worker } from 'node:worker_threads';

async function main() {
  await using worker = new Worker('setInterval(() => {}, 1000)', { eval: true });   // would run forever
  await new Promise((r) => worker.once('online', r));
  console.log('worker online:', worker.threadId);
}                                                                   // leaving the scope calls worker.terminate()
await main();
console.log('scope left, process can exit');
Output on Node.js 26 and 24
worker online: 1
scope left, process can exit

On Node.js 22.23.3 this file does not even parse (SyntaxError: Unexpected identifier 'worker'), because its V8 (12.4) has no await using; the Symbol.asyncDispose method exists since v22.18.0 but you call it by hand there (or use try/finally with await worker.terminate()).

Handling failure

Table 6. Failure modes and what to do
What happens How it shows up and how to handle it

The worker throws and nobody catches it

'error' on the Worker object, then 'exit' with a non-zero code. Without an 'error' listener the error is re-thrown as an uncaught exception in the parent (observed: the process crashed with the worker’s stack).

The worker calls process.exit(code)

Only the worker ends; 'exit' carries the code (observed 3 for process.exit(3)) and no 'error' fires. A promise wrapper must reject on a non-zero code, as above.

The worker is terminated

'exit' with code 1.

The worker hits resourceLimits

'error' with ERR_WORKER_OUT_OF_MEMORY, then 'exit'.

A message cannot be cloned

postMessage() throws DataCloneError at the sender; an undecodable message raises 'messageerror' at the receiver.

A promise rejects inside the worker and is not handled

Observed on Node.js 26 and 22: the worker dies and the parent gets an 'error' event with the original error class, then 'exit' with code 1.

Observing workers

The parent can ask a running worker about its health without that worker’s cooperation: worker.performance.eventLoopUtilization() (the worker’s loop busy fraction, from the same API as perf_hooks), worker.getHeapStatistics() (a promise, like v8.getHeapStatistics(), Node.js 24 and 22.16+), worker.cpuUsage() (a promise of user and system microseconds, 24.6 and 22.19+), worker.getHeapSnapshot(), and, in Node.js 24.8+ and 24.9+, worker.startCpuProfile() and worker.startHeapProfile(). These are the inputs for a pool dashboard and for deciding that a worker is saturated (see Profiling & Performance).

Reading a busy worker’s utilisation, heap and CPU
import { Worker, isMainThread, parentPort } from 'node:worker_threads';

if (isMainThread) {
  const w = new Worker(new URL(import.meta.url));
  await new Promise((r) => w.once('online', r));
  const before = w.performance.eventLoopUtilization();
  await new Promise((r) => setTimeout(r, 300));
  const elu = w.performance.eventLoopUtilization(before);
  console.log('worker ELU while spinning:', elu.utilization.toFixed(2));
  const heap = await w.getHeapStatistics();
  console.log('worker heap used > 0:', heap.used_heap_size > 0);
  const cpu = await w.cpuUsage();
  console.log('worker cpu keys:', Object.keys(cpu).join(','));
  await w.terminate();
} else {
  for (;;) Math.sqrt(Math.random());
}
Output (identical on Node.js 26, 24 and 22)
worker ELU while spinning: 1.00
worker heap used > 0: true
worker cpu keys: user,system

The cost of a worker is not zero. Starting one means booting a new V8 isolate and a Node.js environment; in these runs (macOS arm64) a round trip to a freshly started worker took a median of about 5 ms on Node.js 26 and about 11 ms on 24 and 22, and each idle worker added roughly 5 MiB (26) to 9 MiB (24 and 22) of resident memory. Those are observed, machine-specific numbers, but they explain the first rule of workers: create a few, once, and reuse them.

Measuring worker start-up and idle memory
import { Worker } from 'node:worker_threads';

const times = [];
for (let i = 0; i < 20; i++) {
  const t = performance.now();
  await new Promise((resolve) => {
    const w = new Worker('require("node:worker_threads").parentPort.postMessage(1)', { eval: true });
    w.once('message', () => { times.push(performance.now() - t); w.terminate().then(resolve); });
  });
}
times.sort((a, b) => a - b);
console.log('round trip to a fresh worker (ms): median', times[10].toFixed(1), 'max', times.at(-1).toFixed(1));

const rss0 = process.memoryUsage().rss;
const ws = Array.from({ length: 8 }, () => new Worker('setInterval(() => {}, 1000)', { eval: true }));
await Promise.all(ws.map((w) => new Promise((r) => w.once('online', r))));
await new Promise((r) => setTimeout(r, 300));
console.log('RSS growth per idle worker (MiB):', ((process.memoryUsage().rss - rss0) / 8 / 2 ** 20).toFixed(1));
await Promise.all(ws.map((w) => w.terminate()));

A worker pool

The API documentation says it plainly: spawning a worker per task can cost more than the work saved, so use a pool. A pool owns a fixed number of workers, a queue of jobs and the bookkeeping that matches an answer to the caller that asked. The Bookshelf application needs one for cover images: for every uploaded cover it computes a content hash used as a cache key and for duplicate detection, a CPU-bound job that must not stall the catalogue API. The worker below stands in for real image work with a long hash chain.

The cover-hash worker (hash-worker.js)
import { parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';

// One job = one cover image (an ArrayBuffer that was transferred to us).
parentPort.on('message', ({ id, bytes }) => {
  try {
    const data = new Uint8Array(bytes);
    let digest = createHash('sha256').update(data).digest();
    for (let i = 0; i < 150_000; i++) {                      // stand-in for real CPU work: perceptual hash, resize...
      digest = createHash('sha256').update(digest).update(data.subarray(0, 64)).digest();
    }
    parentPort.postMessage({ id, hash: digest.toString('hex') });
  } catch (error) {
    parentPort.postMessage({ id, error: { name: error.name, message: error.message } });
  }
});

The pool class has four responsibilities: spawn workers on demand up to a limit (the default availableParallelism() - 1 leaves a core for the event loop), queue jobs when everyone is busy, replace a worker that died, and wrap each job in an AsyncResource so that async context and stack traces survive the thread hop (the Node.js documentation shows this pattern for pools).

A small worker pool (pool.js)
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';
import { AsyncResource } from 'node:async_hooks';

export class WorkerPool {
  #file; #size; #idle = []; #all = new Set(); #queue = []; #jobs = new Map(); #nextId = 0; #closed = false;

  constructor(file, size = Math.max(1, availableParallelism() - 1)) {
    this.#file = file;
    this.#size = size;
  }

  /** Queue a job; resolves with the worker's answer. `transfer` lists buffers to move, not copy. */
  run(payload, transfer = []) {
    if (this.#closed) return Promise.reject(new Error('pool is closed'));
    return new Promise((resolve, reject) => {
      const id = this.#nextId++;
      const resource = new AsyncResource('WorkerPoolTask');  // keeps async stack traces / ALS context
      this.#queue.push({ id, payload, transfer, resolve: (v) => resource.runInAsyncScope(resolve, null, v), reject: (e) => resource.runInAsyncScope(reject, null, e) });
      this.#drain();
    });
  }

  #spawn() {
    const worker = new Worker(this.#file);
    worker.on('message', (msg) => this.#settle(worker, msg));
    worker.on('error', (err) => this.#crash(worker, err));         // uncaught error: the worker is already dead
    worker.on('exit', (code) => { if (this.#all.delete(worker)) this.#crash(worker, new Error(`worker exited with ${code}`), false); });
    this.#all.add(worker);
    return worker;
  }

  #drain() {
    while (this.#queue.length) {
      let worker = this.#idle.pop();
      if (!worker && this.#all.size < this.#size) worker = this.#spawn();
      if (!worker) return;                                          // everyone is busy; wait for a free worker
      const job = this.#queue.shift();
      this.#jobs.set(worker, job);
      worker.postMessage({ id: job.id, ...job.payload }, job.transfer);
    }
  }

  #settle(worker, msg) {
    const job = this.#jobs.get(worker);
    this.#jobs.delete(worker);
    this.#idle.push(worker);
    if (msg.error) job.reject(Object.assign(new Error(msg.error.message), { name: msg.error.name }));
    else job.resolve(msg);
    this.#drain();
  }

  #crash(worker, err, remove = true) {
    if (remove) this.#all.delete(worker);
    const job = this.#jobs.get(worker);
    this.#jobs.delete(worker);
    this.#idle = this.#idle.filter((w) => w !== worker);
    job?.reject(err);
    if (!this.#closed) this.#drain();                               // replacement is spawned on demand
  }

  async close() {
    this.#closed = true;
    await Promise.all([...this.#all].map((w) => w.terminate()));
  }
}

The driver compares the sequential baseline with the pool, transfers each cover’s ArrayBuffer, verifies that both give identical hashes, and shows that a failing job rejects only its own promise:

Hashing covers with the pool (covers.js)
import { WorkerPool } from './pool.js';
import { randomFillSync } from 'node:crypto';
import { createHash } from 'node:crypto';

const covers = Array.from({ length: 16 }, (_, i) => ({ isbn: `978-0-00-${String(i).padStart(6, '0')}`, bytes: randomFillSync(new Uint8Array(256 * 1024)) }));

// baseline: the same work on the main thread, one cover after another
function hashHere(data) {
  let d = createHash('sha256').update(data).digest();
  for (let i = 0; i < 150_000; i++) d = createHash('sha256').update(d).update(data.subarray(0, 64)).digest();
  return d.toString('hex');
}
let t = performance.now();
const expected = covers.map((c) => hashHere(c.bytes));
console.log('sequential, main thread:', Math.round(performance.now() - t), 'ms');

const pool = new WorkerPool(new URL('./hash-worker.js', import.meta.url), 4);
t = performance.now();
const results = await Promise.all(covers.map(({ bytes }) => {
  const copy = bytes.slice();                                       // keep ours; transfer the copy
  return pool.run({ bytes: copy.buffer }, [copy.buffer]);
}));
console.log('pool of 4              :', Math.round(performance.now() - t), 'ms');
console.log('identical hashes       :', results.every((r, i) => r.hash === expected[i]));

// a job that fails inside the worker rejects its own promise and leaves the pool healthy
await pool.run({ bytes: -1 }).catch((e) => console.log('bad job rejected      :', e.name));
console.log('pool still works       :', (await pool.run({ bytes: new ArrayBuffer(8) })).hash.slice(0, 8));
await pool.close();
Observed output (Node.js 26.10.0, macOS arm64, while other work was running on the machine)
sequential, main thread: 997 ms
pool of 4              : 744 ms
identical hashes       : true
bad job rejected      : RangeError
pool still works       : 7efe150c

Node.js 24.21.0 measured 1214 ms against 701 ms and Node.js 22.23.3 1256 ms against 787 ms. The speed-up is smaller than the worker count suggests because the jobs are short, worker start-up is included, and the measurements ran on a shared machine; with longer jobs it approaches the number of physical cores. What does not depend on the machine: the main thread stayed free during the pool run, and the hashes are bit-identical.

Wiring it to an HTTP endpoint shows the Bookshelf use. The handler reads the upload, copies it into a fresh ArrayBuffer that is safe to transfer, awaits the pool, and answers; the event loop stays free for other requests the whole time.

A Bookshelf endpoint backed by the pool (server.js)
import http from 'node:http';
import { WorkerPool } from './pool.js';

const pool = new WorkerPool(new URL('./hash-worker.js', import.meta.url), 2);

const server = http.createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/covers/hash') {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = Buffer.concat(chunks);
    const bytes = new Uint8Array(body).buffer;               // own copy, safe to transfer
    try {
      const { hash } = await pool.run({ bytes }, [bytes]);
      res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ bytes: body.length, hash }));
    } catch (err) {
      res.writeHead(500).end(err.message);
    }
  } else {
    res.writeHead(404).end();
  }
});

await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();

// exercise it: four uploads in parallel, the event loop stays responsive
const started = performance.now();
const replies = await Promise.all(Array.from({ length: 4 }, (_, i) =>
  fetch(`http://127.0.0.1:${port}/covers/hash`, { method: 'POST', body: Buffer.alloc(100_000, i) }).then((r) => r.json())));
console.log(replies.map((r) => `${r.bytes} B -> ${r.hash.slice(0, 12)}`));
console.log('total ms:', Math.round(performance.now() - started));

server.close();
await pool.close();
Observed output (Node.js 26; the other lines behave the same)
[
  '100000 B -> ed8e655a629c',
  '100000 B -> 0ed0ccc82325',
  '100000 B -> f0c9ce7e08de',
  '100000 B -> fd3bca9ecdff'
]
total ms: 362
Table 7. What a production pool still needs
Concern Approach

Pool size

At most the number of cores, usually fewer than that. More workers than cores only add context switches. Leave a core for the main thread.

Back-pressure

The queue above is unbounded. Reject new work (HTTP 503) or limit the queue length once it grows, otherwise a burst turns into memory growth.

Timeouts

A job that never finishes occupies a worker forever. Race it against a timer and terminate() the worker, then replace it.

Crash handling

Reject the in-flight job on 'error' or an unexpected 'exit' and start a replacement lazily, as the class does.

Shutdown

close() terminates workers; call it from your signal handler after you stop accepting work (see Process, Signals & Exit Codes).

Per-job cancellation

Send a cancel message that the worker polls, or a shared flag checked with Atomics.load() as in the next section. See Async Context & Cancellation for AbortSignal.

Ready-made libraries

A maintained pool such as Piscina packages these concerns (queueing, task cancellation, resource limits, statistics). It is third-party; evaluate it like any dependency. Its README and site are listed in the references.

For small, rare jobs, an in-process pool of one worker with a request queue is enough; for I/O-bound work, none of this is needed.

Proof of work in parallel

A proof-of-work puzzle asks for a number (a nonce) that, appended to a block of data and hashed with SHA-256, produces a digest that starts with a given number of zero characters. Nothing makes it faster except trying more values at once, so it is the textbook CPU-bound loop: on the main thread it would freeze every other request in a blockchain-style server. The program below splits the nonce space across four workers (worker i tries i, i plus 4, i plus 8, and so on), shares a two-slot Int32Array over a SharedArrayBuffer for coordination, and stops the others as soon as one finds an answer.

The shared state is the whole synchronisation design: slot 0 is a stop flag read with Atomics.load() every 16,384 attempts (cheap), and Atomics.compareExchange() guarantees that exactly one worker publishes a result even if two find valid nonces at the same moment.

A parallel miner with a shared stop flag (pow.js)
import { Worker, isMainThread, workerData, parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { availableParallelism } from 'node:os';

// Shared state: [0] = stop flag, [1] = winning nonce (low 31 bits are enough here)
const STOP = 0, NONCE = 1;

if (!isMainThread) {
  const { shared, block, difficulty, start, step } = workerData;
  const state = new Int32Array(shared);
  const prefix = '0'.repeat(difficulty);
  let tried = 0;
  for (let nonce = start; ; nonce += step) {
    if ((tried++ & 0x3fff) === 0 && Atomics.load(state, STOP) === 1) break;   // cheap periodic check
    const hash = createHash('sha256').update(`${block}:${nonce}`).digest('hex');
    if (hash.startsWith(prefix)) {
      if (Atomics.compareExchange(state, STOP, 0, 1) === 0) {                  // first finder wins
        Atomics.store(state, NONCE, nonce);
        parentPort.postMessage({ nonce, hash });
      }
      break;
    }
  }
  parentPort.postMessage({ tried });
} else {
  const block = 'bookshelf-block-1|prev=0000|data=alice->bob:5';
  const difficulty = 5;
  const threads = Math.min(4, availableParallelism());
  const shared = new SharedArrayBuffer(8);
  const state = new Int32Array(shared);
  const started = performance.now();
  const workers = Array.from({ length: threads }, (_, i) =>
    new Worker(new URL(import.meta.url), { workerData: { shared, block, difficulty, start: i, step: threads } }));
  let answer, total = 0;
  await Promise.all(workers.map((w) => new Promise((resolve, reject) => {
    w.on('message', (m) => { if (m.hash) answer = m; if (m.tried) total += m.tried; });
    w.once('error', reject);
    w.once('exit', resolve);
  })));
  const check = createHash('sha256').update(`${block}:${answer.nonce}`).digest('hex');
  console.log({ threads, nonce: answer.nonce, hash: answer.hash, verified: check === answer.hash, attempts: total, ms: Math.round(performance.now() - started) });
}
Observed output (Node.js 26; the winning nonce and the elapsed time can differ between runs)
{
  threads: 4,
  nonce: 2682751,
  hash: '000009bbcb0690171e8c8e21427f22a805ce737597ae87639657e512791a7e40',
  verified: true,
  attempts: 2685923,
  ms: 438
}

The parent recomputes the hash itself (verified: true): never trust a result that you can check cheaply. Difficulty grows by a factor of 16 for each additional leading hex zero, so difficulty = 6 is about sixteen times the work. In a server, mining runs in a pool like the one above, and the HTTP handler awaits the result; use listen({ port: 0 }) in tests, as the other examples on this page do.

worker_threads versus Web Workers, and the experimental web Worker global

node:worker_threads and browser Web Workers solve the same problem and look alike, but they are not the same API. The Worker class that Node.js documentation describes is Node-specific; the browser Worker is defined by the HTML Standard.

Table 8. Comparison
Aspect node:worker_threads Browser Web Workers (and Node.js Worker global)

How you get it

import { Worker } from 'node:worker_threads'

A global Worker. Node.js 26.9+ provides one behind --experimental-web-worker.

Events

Node EventEmitter: worker.on('message', fn), 'online', 'error', 'exit'

DOM EventTarget: addEventListener('message', event ⇒ …​), event.data; onmessage.

Inside the worker

parentPort, workerData, isMainThread, full Node.js API and require/import

self, postMessage(), close(), importScripts(), name, location, navigator.

Starting data

workerData, argv, env, execArgv

Only messages after creation (plus the name option).

Resource and permission control

resourceLimits, --allow-worker

Browser-imposed (origin, CSP, memory).

Shared memory

SharedArrayBuffer and Atomics, no isolation headers needed

Same API, but browsers require cross-origin isolation (COOP and COEP headers).

Other worker types

None

SharedWorker and Service Workers exist in browsers; SharedWorker is not implemented in Node.js.

Script loading

Paths or file: / data: URLs

Fetched over the network; the Node.js web Worker accepts only file:, data: and blob: URLs.

The browser side, including SharedWorker, Service Workers and MessageChannel between pages and workers, is covered in Web Workers. The messaging model is deliberately the same (structured clone, transfer lists), so knowledge of one carries over: postMessage(value, [buffer]) means the same in both.

Node.js 26.9 added an experimental global Worker that is "mostly browser-compatible", implemented on top of node:worker_threads: its threads get a DedicatedWorkerGlobalScope with self, name, location, navigator, postMessage(), close() and importScripts(), alongside the usual Node.js globals such as process. It exists to let code written for browsers (or a polyfill-free library) run in Node.js. Differences from the HTML Standard that the docs list include: no origin model (location.origin is 'null'), close() terminates immediately, and no SharedWorker.

The web-style Worker global (needs --experimental-web-worker, Node.js 26.9+)
const worker = new Worker(new URL('./web-worker-script.js', import.meta.url), { name: 'greeter' });
worker.addEventListener('message', (event) => {
  console.log(event.data);
  worker.terminate();
});
worker.postMessage('Hello');
The worker script
addEventListener('message', (event) => {
  postMessage(`${event.data} from ${name} (self === globalThis: ${self === globalThis}, has process: ${typeof process})`);
});
node --experimental-web-worker webworker.mjs
Observed output on Node.js 26.10.0
Hello from greeter (self === globalThis: true, has process: object)

Without the flag, typeof Worker is 'undefined' on Node.js 26.10.0, 24 and 22 (observed), and on 24 and 22 the flag itself is rejected as a bad option. Because the API is Experimental it can change in a minor release; new Node.js code should keep using node:worker_threads, and treat the global as a portability aid.

What changed recently

Table 9. Changes, newest first
Version Change

v26.9.0

Experimental browser-style Worker global and --experimental-web-worker.

v26.x docs

net.BoundSocket is listed as a transferable handle and TCP net.Server/net.Socket transfers have their own section in the net docs.

v24.9.0 and v22.20.0

worker.startHeapProfile(); worker_threads.threadName and worker.threadName arrived in v24.6.0 and v22.20.0.

v24.8.0

worker.startCpuProfile().

v24.6.0 and v22.19.0

worker.cpuUsage().

v24.5.0

worker_threads.locks (Experimental).

v24.2.0 and v22.18.0

worker[Symbol.asyncDispose]().

v24.0.0 and v22.16.0

worker.getHeapStatistics().

v23.7.0 and v22.14.0

worker_threads.isInternalThread.

v23.0.0 and v22.10.0

markAsUncloneable().

v22.5.0 and v20.19.0

postMessageToThread() and the workerMessage event (1.1).

v21.0.0

An error is thrown when an untransferable object is in a transfer list (earlier versions did not).

v20.0.0

--allow-worker introduced with the permission model.

v19.8.0 and v18.16.0

name option of the Worker constructor.

The complete cross-section list for upgrades is in Upgrading & What Changed.

Where the books are outdated

Neither book treats node:worker_threads as a first-class subject, and that is the main gap this page fills. Efficient Node.js (Samer Buna, O’Reilly, 2025) scales a server across cores with node:cluster and child processes, which are different tools with different costs (full processes, no shared memory); thread-based parallelism with transferables, SharedArrayBuffer and Atomics is the missing piece. Node.js Projects (Jonathan Wexler, O’Reilly, 2025) builds a hash-chain with a proof-of-work step, which is exactly a CPU-bound loop; read it with this page beside it to move that loop off the event loop with a worker pool. Where either book calls an API "experimental" or sends only plain data and JSON to another process, the current documentation is more capable: node:worker_threads is Stable and has been since v12.11.0, structured clone replaces JSON for messages between threads, and a pool of workers needs no process-per-request design. Also note that the permission model (where a worker needs --allow-worker) did not exist for most older tutorials.

Pitfalls

Table 10. Common mistakes and the safe alternative
Mistake Alternative

Using workers for I/O-bound work (database calls, HTTP)

Use the asynchronous APIs, which are more efficient than a worker can be.

Starting a worker for every request

Reuse a small pool; start-up costs milliseconds and several MiB.

Posting a function, a class instance or a request object

Post plain data; rebuild objects inside the worker.

Using counter[0]++ on shared memory

Atomics.add() or another atomic operation.

Calling Atomics.wait() on the main thread

Atomics.waitAsync(), or a message.

Transferring a Buffer from the pool

Copy into a fresh ArrayBuffer first, or use the pool’s own memory only inside one thread.

Forgetting 'error' and non-zero 'exit' handlers

Always handle both; a promise wrapper must reject on non-zero exit.

Starting workers from a preload script without a guard

Check isMainThread or set execArgv.

Assuming process.env writes are shared

They are a copy unless SHARE_ENV is passed.

Assuming a resource limit protects the whole process

It limits the worker’s JS heap only; keep process-level memory limits too.

Treating the Worker global as production API

It is Experimental (26.9) and flag-gated; use node:worker_threads.

References