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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Added in v10.5.0. No longer behind |
|
Stable |
|
|
Stable |
Added in v15.4.0, no longer experimental in v18.0.0. |
|
Stable |
Environment data no longer experimental in v17.5.0 and v16.15.0; |
|
Stable |
Added in v13.2.0 and v12.16.0. |
|
Stable |
|
|
Stable |
|
|
Stable |
|
|
Stable |
Added in v24.2.0 and v22.18.0. The |
|
1.1 Active development |
Added in v22.5.0 and v20.19.0. |
|
1 Experimental |
Added in v24.5.0; absent on Node.js 22 (the named import fails). |
Transferring a TCP |
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 |
Global |
1 Experimental |
Added in v26.9.0. The flag is unknown on Node.js 24 and 22. |
|
1.1 Active development |
Added in v20.0.0. With |
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.
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);
}));
}
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:
| Tool | Use it for | Cost and limits |
|---|---|---|
Asynchronous APIs ( |
Anything that waits: files, sockets, databases, HTTP |
No extra threads of yours. Some operations run on libuv’s thread pool (default 4 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. |
|
Other programs, or isolation from your own code: see Child Processes |
A full process per child, IPC by serialisation, no shared memory. |
|
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.
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) });
}
Source: Node.js API: Class Worker
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:
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));
}
Source: Node.js API: Worker threads
The constructor accepts these arguments and options; the Node.js docs list more, these are the ones you use daily:
| Argument or option | What it does |
|---|---|
|
A path or a |
|
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. |
|
Extra values appended to |
|
Initial |
|
Node.js command-line options for the worker. By default the worker inherits the parent’s options. V8 options such as |
|
With |
|
Heap, young generation, code range and stack limits for the worker (see the resource-limits section). |
|
A label used in the thread name and worker title for debugging; the final title is |
|
Defaults to |
|
Objects (for example a |
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:
| Event | When it fires |
|---|---|
|
The worker started executing JavaScript. |
|
The worker called |
|
A message could not be deserialised on the receiving side. |
|
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). |
|
Always the last event. The code is the one passed to |
Source: Node.js API: Worker events
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}`));
Source: Node.js API: Worker threads
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.
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(' '),
});
}
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:
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()));
Source: Node.js API: worker.stdout
[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.
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));
import { parentPort, workerData } from 'node:worker_threads';
const { n } = workerData as { n: number };
parentPort!.postMessage(n * n);
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.
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)
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.
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);
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.
| 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 |
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 |
Needless: structured clone is faster for typed arrays and keeps |
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.
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
});
}
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.
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();
{ 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.
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');
}
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.
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);
}
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.
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();
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.
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');
});
}
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().
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
}
}
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.
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));
}
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
SharedArrayBufferonly 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
Mapcannot live in shared memory. -
Use
Atomicsfor 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.
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()));
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.
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));
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.
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);
Source: Node.js API: worker.terminate()
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:
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');
Source: Node.js API: worker[Symbol.asyncDispose()]
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
| What happens | How it shows up and how to handle it |
|---|---|
The worker throws and nobody catches it |
|
The worker calls |
Only the worker ends; |
The worker is terminated |
|
The worker hits |
|
A message cannot be cloned |
|
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 |
Source: Node.js API: Worker events
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).
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());
}
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.
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()));
Source: Node.js API: Worker threads
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.
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).
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()));
}
}
Source: Node.js API: Using AsyncResource for a Worker thread pool, Node.js API: os.availableParallelism()
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:
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();
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.
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();
[
'100000 B -> ed8e655a629c',
'100000 B -> 0ed0ccc82325',
'100000 B -> f0c9ce7e08de',
'100000 B -> fd3bca9ecdff'
]
total ms: 362
| 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 |
Crash handling |
Reject the in-flight job on |
Shutdown |
|
Per-job cancellation |
Send a cancel message that the worker polls, or a shared flag checked with |
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.
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) });
}
{
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.
| Aspect | node:worker_threads |
Browser Web Workers (and Node.js Worker global) |
|---|---|---|
How you get it |
|
A global |
Events |
Node |
DOM |
Inside the worker |
|
|
Starting data |
|
Only messages after creation (plus the |
Resource and permission control |
|
Browser-imposed (origin, CSP, memory). |
Shared memory |
|
Same API, but browsers require cross-origin isolation ( |
Other worker types |
None |
|
Script loading |
Paths or |
Fetched over the network; the Node.js web |
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.
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');
addEventListener('message', (event) => {
postMessage(`${event.data} from ${name} (self === globalThis: ${self === globalThis}, has process: ${typeof process})`);
});
node --experimental-web-worker webworker.mjs
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
| Version | Change |
|---|---|
v26.9.0 |
Experimental browser-style |
v26.x docs |
|
v24.9.0 and v22.20.0 |
|
v24.8.0 |
|
v24.6.0 and v22.19.0 |
|
v24.5.0 |
|
v24.2.0 and v22.18.0 |
|
v24.0.0 and v22.16.0 |
|
v23.7.0 and v22.14.0 |
|
v23.0.0 and v22.10.0 |
|
v22.5.0 and v20.19.0 |
|
v21.0.0 |
An error is thrown when an untransferable object is in a transfer list (earlier versions did not). |
v20.0.0 |
|
v19.8.0 and v18.16.0 |
|
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
| 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 |
|
Calling |
|
Transferring a |
Copy into a fresh |
Forgetting |
Always handle both; a promise wrapper must reject on non-zero exit. |
Starting workers from a preload script without a guard |
Check |
Assuming |
They are a copy unless |
Assuming a resource limit protects the whole process |
It limits the worker’s JS heap only; keep process-level memory limits too. |
Treating the |
It is Experimental (26.9) and flag-gated; use |
References
-
Node.js API: Worker threads, https://nodejs.org/docs/latest/api/worker_threads.html
-
Node.js API: Class Worker and new Worker(), https://nodejs.org/docs/latest/api/worker_threads.html#class-worker
-
Node.js API: port.postMessage(), https://nodejs.org/docs/latest/api/worker_threads.html#portpostmessagevalue-transferlist
-
Node.js API: Class MessageChannel, https://nodejs.org/docs/latest/api/worker_threads.html#class-messagechannel
-
Node.js API: worker_threads.receiveMessageOnPort(), https://nodejs.org/docs/latest/api/worker_threads.html#worker_threadsreceivemessageonportport
-
Node.js API: Class BroadcastChannel, https://nodejs.org/docs/latest/api/worker_threads.html#class-broadcastchannel-extends-eventtarget
-
Node.js API: worker_threads.postMessageToThread(), https://nodejs.org/docs/latest/api/worker_threads.html#worker_threadspostmessagetothreadthreadid-value-transferlist-timeout
-
Node.js API: worker_threads.markAsUntransferable(), https://nodejs.org/docs/latest/api/worker_threads.html#worker_threadsmarkasuntransferableobject
-
Node.js API: worker_threads.locks, https://nodejs.org/docs/latest/api/worker_threads.html#worker_threadslocks
-
Node.js API: worker.performance, https://nodejs.org/docs/latest/api/worker_threads.html#workerperformance
-
Node.js API: new Worker(), https://nodejs.org/docs/latest/api/worker_threads.html#new-workerfilename-options
-
Node.js API: worker_threads.parentPort, https://nodejs.org/docs/latest/api/worker_threads.html#worker_threadsparentport
-
Node.js API: Class Worker (global, experimental), https://nodejs.org/docs/latest/api/globals.html#class-worker
-
Node.js API: Worker, differences from the HTML Standard, https://nodejs.org/docs/latest/api/globals.html#differences-from-the-html-standard
-
Node.js API: --experimental-web-worker, https://nodejs.org/docs/latest/api/cli.html#--experimental-web-worker
-
Node.js API: --allow-worker, https://nodejs.org/docs/latest/api/cli.html#--allow-worker
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html
-
Node.js API: Permission model, https://nodejs.org/docs/latest/api/permissions.html#permission-model
-
Node.js API: Using AsyncResource for a Worker thread pool, https://nodejs.org/docs/latest/api/async_context.html#using-asyncresource-for-a-worker-thread-pool
-
Node.js API: Transferring TCP handles to other threads, https://nodejs.org/docs/latest/api/net.html#transferring-tcp-handles-to-other-threads
-
Node.js API: ERR_WORKER_OUT_OF_MEMORY, https://nodejs.org/docs/latest/api/errors.html#err_worker_out_of_memory
-
Node.js API: ERR_ACCESS_DENIED, https://nodejs.org/docs/latest/api/errors.html#err_access_denied
-
Node.js API: os.availableParallelism(), https://nodejs.org/docs/latest/api/os.html#osavailableparallelism
-
Node.js API: perf_hooks eventLoopUtilization(), https://nodejs.org/docs/latest/api/perf_hooks.html#perf_hookseventlooputilizationutilization1-utilization2
-
Node.js API: Cluster, https://nodejs.org/docs/latest/api/cluster.html
-
Node.js API: Child process, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: v8 serialization API, https://nodejs.org/docs/latest/api/v8.html#serialization-api
-
Node.js API: Type stripping, https://nodejs.org/docs/latest/api/typescript.html#type-stripping
-
Node.js Learn: Comparing Node.js concurrency models, https://nodejs.org/learn/concurrency/comparing-nodejs-concurrency-models
-
Node.js Learn: Don’t block the event loop, https://nodejs.org/learn/asynchronous-work/dont-block-the-event-loop
-
Node.js Learn: Running TypeScript natively, https://nodejs.org/learn/typescript/run-natively
-
MDN: Web Workers API, https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
-
MDN: The structured clone algorithm, https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
-
MDN: Atomics, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Atomics
-
MDN: SharedArrayBuffer, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
-
MDN: DedicatedWorkerGlobalScope, https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope
-
HTML Standard: Web workers, https://html.spec.whatwg.org/multipage/workers.html
-
Piscina (third-party worker pool), https://github.com/piscinajs/piscina
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/