Concurrency Models
|
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 below was run on all three lines with the same output apart from timings.
Node.js runs your JavaScript on one thread driven by the event loop. That is ideal for I/O-bound work, because the waiting is delegated to the
operating system, and a poor fit for two situations: CPU-bound work that holds the thread, and using more than one CPU core. This page is the map
of the options. node:worker_threads, node:child_process and node:cluster are all Stability 2 (Stable) APIs; the web Worker global is
Experimental and flag-gated, and the --allow-worker and --allow-child-process flags of the (Stable) permission model are themselves Stability 1.1 (Active development). Each option has its own
deep-dive page: Worker Threads, Child Processes and
Cluster & Scaling. The loop itself is explained in The Event Loop.
The running scenario is the Bookshelf application described in Introduction & Architecture.
Five ways to run more work at once
| Option | Unit of execution | Memory | Talks via | Reach for it when |
|---|---|---|---|---|
Event loop (no extra thread) |
One JavaScript thread, plus libuv’s thread pool for some native work |
Shared by everything on the loop |
Callbacks, promises, events |
Work that mostly waits: network, files, databases. Many thousands of connections cost little. |
|
A thread with its own V8 isolate and event loop inside the same process |
Isolated heap; can share a |
|
CPU-bound JavaScript that would stall the loop: hashing, parsing, compression, image work. |
|
A separate operating-system process, Node.js or not |
Fully isolated |
stdio, or an IPC channel for |
Running another program, or a Node.js script you want isolated from the parent. |
|
Several Node.js processes started by a primary, sharing one listening port |
Fully isolated |
IPC messages |
Using every core of one machine for a network server. |
Multiple containers or hosts |
Whole copies of the application managed by an orchestrator |
Fully isolated |
The network, a queue, a database |
Scaling past one machine, rolling updates, failure isolation, per-instance resource limits. |
The Learn article states the relationship that makes the choice easier: cluster is built on child_process to scale a server across cores, while
worker_threads exists to run CPU-heavy code without the cost of a whole process. The options are not exclusive. A common arrangement is one
container per machine, a cluster (or several containers) to use the cores, and a worker thread inside each process for the heavy computation.
Decision chart
The chart follows the questions in the order that usually settles the choice: first whether the work is blocking at all, then whether it is another program, then what has to be shared and where the instances run.
The event loop is concurrency, not parallelism
The loop interleaves many tasks on one thread; two callbacks never run at the same instant. That is why a single synchronous computation delays
every other client. The example below hashes a password five times, once directly on the main thread and once in a worker, while a 10 ms
heartbeat timer counts how often the loop got a turn. The same file is both the main program and the worker, selected with isMainThread.
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import { scryptSync } from 'node:crypto';
// CPU-bound work: derive keys (pure computation, no I/O).
function hashPasswords(password, rounds) {
let out = '';
for (let i = 0; i < rounds; i++) {
out = scryptSync(password + i, 'demo-salt-0123456', 64, { N: 2 ** 15, maxmem: 64 * 1024 * 1024 }).toString('hex');
}
return out;
}
if (!isMainThread) {
parentPort.postMessage(hashPasswords(workerData, 5));
} else {
const mode = process.argv[2] ?? 'inline';
// A heartbeat that should fire every 10 ms; few ticks mean the event loop was blocked.
let last = performance.now();
let worstGap = 0;
let ticks = 0;
const heartbeat = setInterval(() => {
const now = performance.now();
ticks++;
worstGap = Math.max(worstGap, now - last);
last = now;
}, 10);
const started = performance.now();
const result = mode === 'worker'
? await new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), { workerData: 'correct horse' });
worker.once('message', resolve);
worker.once('error', reject);
})
: hashPasswords('correct horse', 5);
const gapAtEnd = performance.now() - last;
worstGap = Math.max(worstGap, gapAtEnd);
clearInterval(heartbeat);
console.log(`${mode}: ${result.slice(0, 8)}… in ${Math.round(performance.now() - started)} ms, ` +
`${ticks} heartbeats, worst gap ${Math.round(worstGap)} ms`);
}
Run with node lag.js inline and node lag.js worker. The output below was observed on Node.js 26.10.0; Node.js 24 and 22 produced the same
shape (about 200 ms of work, zero heartbeats inline, 20 to 23 with the worker). Timings come from one laptop and will differ on yours.
inline: 1883d4d7… in 203 ms, 0 heartbeats, worst gap 203 ms
worker: 1883d4d7… in 226 ms, 20 heartbeats, worst gap 11 ms
The worker run is slightly slower in total because starting a thread has a cost; what it buys is a loop that keeps serving timers, sockets and
other requests. libuv’s thread pool (four threads by default, UV_THREADPOOL_SIZE) already runs some native work off-thread, such as file system
calls, DNS lookups and crypto functions with an asynchronous form, which is why fs.readFile() does not block your loop but a synchronous
scryptSync() does. Details are in Don’t Block the Event Loop.
Worker threads: parallel JavaScript in one process
A worker has its own V8 isolate, event loop and module graph, so its globals are not shared with the main thread. Data crosses the boundary by
structured clone (copied), by transfer (ownership moves, for example an ArrayBuffer), or through a SharedArrayBuffer that both sides read and
write with Atomics. Shared memory removes copying but brings the data races of any threaded language back, so use it only when profiling shows
that copying is the cost. Workers are for CPU-bound code: a worker waiting on a database is no better off than the main thread, and creating one
per task wastes the startup cost, which is why real applications keep a pool of long-lived workers. The lag.js example above creates a worker per
call only to keep the code short. A complete pool, resourceLimits, transferables and the experimental web Worker global are covered in
Worker Threads.
Child processes: another program, or an isolated script
child_process is the right tool when the work is not your JavaScript at all (a converter, a compiler, a command-line tool), or when you want a
crash-isolated Node.js process. spawn() streams output, exec() buffers it through a shell and caps it at 1 MiB by default (maxBuffer),
execFile() runs a known binary without a shell, and fork() starts a Node.js script with an IPC channel for structured messages. Passing an
argument array to execFile() or spawn() avoids shell injection; DEP0190, a runtime deprecation since v24.0.0 (documentation-only since 23.11
and 22.15), warns when an argument array is combined with shell: true.
import { execFile, fork } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
if (process.argv[2] === 'child') {
// Child side: receive a job over IPC, answer, and exit.
process.once('message', (job) => {
process.send({ id: job.id, square: job.n ** 2 }, () => process.disconnect());
});
} else {
// 1. Run another program with an argument array (no shell involved).
const { stdout } = await run(process.execPath, ['--version'], { timeout: 5_000 });
console.log('node says', stdout.trim());
// 2. Run another Node.js script with a structured IPC channel.
const child = fork(new URL(import.meta.url), ['child'], { timeout: 5_000 });
const answer = await new Promise((resolve, reject) => {
child.once('message', resolve);
child.once('error', reject);
child.once('exit', (code) => code && reject(new Error(`child exited with ${code}`)));
child.send({ id: 1, n: 12 });
});
console.log('child answered', answer);
}
Observed output on Node.js 26.10.0, 24.21.0 and 22.23.3 (the first line shows the running version):
node says v26.10.0
child answered { id: 1, square: 144 }
Source: Node.js API: Child process
The child here terminates itself after one answer and the parent also passes a timeout, so a stuck child cannot live forever. Handle both
'exit' and 'close' in real code, keep fork() messages small and bounded, and see Child Processes for
detached, unref(), AbortSignal and Windows .bat and .cmd pitfalls.
Cluster: one primary, several workers, one port
cluster starts several copies of the same Node.js program and lets them listen on the same port. By default (cluster.SCHED_RR, the default on
every operating system except Windows) the primary accepts connections and hands them to workers in round-robin order; setting
cluster.schedulingPolicy to cluster.SCHED_NONE, or the NODE_CLUSTER_SCHED_POLICY environment variable to none, leaves distribution to the
operating system. The policy is frozen once the first worker is forked. Distribution is per connection, not per request.
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
const WORKERS = Math.min(availableParallelism(), 3);
if (cluster.isPrimary) {
let ready = 0;
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect) return; // deliberate shutdown: do not respawn
console.log(`worker ${worker.process.pid} died (${signal ?? code}), restarting`);
cluster.fork();
});
cluster.on('listening', async (worker, address) => {
if (++ready < WORKERS) return;
// All workers share one port that the primary owns. The primary hands out whole
// connections, so ask for a fresh connection each time (no keep-alive).
const seen = new Set();
for (let i = 0; i < 9; i++) {
seen.add(await (await fetch(`http://127.0.0.1:${address.port}/`, { headers: { connection: 'close' } })).text());
}
console.log(`policy=${cluster.schedulingPolicy === cluster.SCHED_RR ? 'round-robin' : 'none'}, ` +
`${seen.size} distinct workers answered`);
cluster.disconnect(); // graceful: workers close their servers and exit
});
for (let i = 0; i < WORKERS; i++) cluster.fork();
} else {
http.createServer((req, res) => {
res.end(`worker ${process.pid}`);
}).listen(Number(process.env.PORT ?? 0)); // port 0 here only keeps the demo collision-free
}
Observed output on all three lines, on a machine reporting ten available cores (the demo caps the workers at three):
policy=round-robin, 3 distinct workers answered
Two details are worth observing for yourself. Without connection: close, the default keep-alive behaviour of fetch() reuses one connection, so
in a trial run of an earlier version of this demo fewer than three workers answered: round-robin balances connections, not requests. And a worker
that exits without having called disconnect() is a crash, which is why the handler checks worker.exitedAfterDisconnect before respawning.
Workers are separate processes, so in-memory sessions, caches and rate limiters are per worker. Zero-downtime restarts, sticky sessions, PM2 and
load testing are in Cluster & Scaling; stopping workers cleanly is a signal-handling matter covered in
Process Signals & Exit Codes.
Multiple containers: the same idea one level up
Running one Node.js process per container and letting an orchestrator create replicas gives the same cloning as cluster, plus things a primary
process cannot offer: placement on several machines, rolling updates, health checks that replace a failed instance, and a hard CPU and memory limit
per instance. The cost is a network hop and an external load balancer. A Compose file expresses the idea in a few lines; the replica count and
limits shown here are placeholders to adapt, and the image name is fictional. The snippet was validated with docker compose config.
services:
bookshelf:
image: registry.example.com/bookshelf:1.0.0
deploy:
replicas: 4
resources:
limits:
cpus: "1.0"
memory: 512M
environment:
NODE_ENV: production
expose:
- "3000"
Which is better, cluster or replicas? On one host with no orchestrator, cluster is the smallest step. Where containers already exist, one
process per container is simpler to observe and restart, and the number of cores is then a deployment setting instead of a line of application code.
Take care when sizing: os.availableParallelism() is an estimate supplied by libuv, so check what it reports inside your container before basing a
worker count on it. The comparison is continued in Production Deployment and
Docker & Containers; the existing Docker section covers images and Compose in general, and
Orchestration: Swarm and Kubernetes covers the orchestrators.
Scaling strategies: cloning, decomposing, splitting
The mechanisms above answer "how do I use more CPUs"; the strategies below answer "how do I divide the application". They are listed from the cheapest to the most invasive, and real systems combine them.
| Strategy | Idea | Node.js and platform tools | Main trade-off |
|---|---|---|---|
Cloning |
Run identical copies of the whole application and spread the load across them. |
|
Cheap and effective, but each copy must be stateless or keep its state in a shared store. |
Decomposing |
Divide by function: separate applications with their own code, and often their own data, for separate responsibilities. |
Separate Node.js services that talk over HTTP, queues or messages; see Microservices Architecture & Patterns. |
Independent scaling and deployment, at the price of distributed-system complexity. |
Splitting |
Divide by data: each instance owns one slice (a shard), chosen by a key such as a user, tenant or ISBN. |
A routing step that maps a key to an instance; the same shape works for worker threads, cluster workers or separate services. |
Removes shared state and locking, but needs a lookup before every operation and makes cross-shard queries expensive. |
Splitting is the least familiar of the three, so here is its smallest form: a router hashes the key and always sends it to the same worker, and each
worker owns a private Map. Because the slices are disjoint, no locks are needed.
import { Worker, isMainThread, parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';
const SHARDS = 4;
if (!isMainThread) {
// Each shard owns a disjoint slice of the data, so no locking is needed.
const shelf = new Map();
parentPort.on('message', ({ id, op, key, value }) => {
if (op === 'set') shelf.set(key, value);
parentPort.postMessage({ id, value: shelf.get(key), size: shelf.size });
});
} else {
const workers = Array.from({ length: SHARDS }, () => new Worker(new URL(import.meta.url)));
const pending = new Map();
let nextId = 0;
workers.forEach((w) => w.on('message', (m) => pending.get(m.id)(m)));
// Stable hash of the key picks the shard: the same key always lands on the same worker.
const shardOf = (key) => createHash('sha256').update(key).digest().readUInt32BE(0) % SHARDS;
const call = (msg) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
workers[shardOf(msg.key)].postMessage({ ...msg, id });
});
for (const isbn of ['978-0', '978-1', '978-2', '978-3', '978-4', '978-5', '978-6', '978-7']) {
await call({ op: 'set', key: isbn, value: `book ${isbn}` });
}
console.log((await call({ op: 'get', key: '978-3' })).value);
console.log('same key, same shard:', shardOf('978-3') === shardOf('978-3'));
await Promise.all(workers.map((w) => w.terminate()));
}
Observed output on all three lines:
book 978-3
same key, same shard: true
The same routing function would pick between cluster workers or between services; a plain modulo, as used here, reshuffles most keys when the shard count changes, so production systems use consistent hashing or a lookup table when shards are added.
Common mistakes
| Mistake | Better |
|---|---|
Using a worker thread for a database call or an HTTP request |
Keep it on the event loop; workers are for CPU-bound code, and asynchronous I/O already overlaps. |
Creating a worker or a process for every small task |
Reuse a pool of long-lived workers; startup cost can exceed the work. |
Keeping sessions, caches or counters in memory and then adding cluster workers or replicas |
Move the state to a database or cache, or split by key so each instance owns its slice. |
Calling |
Use |
Respawning every exited cluster worker |
Check |
Choosing a worker count from the host’s core count inside a limited container |
Make the count a setting and verify what |
Assuming more processes always means more speed |
Measure first; see Don’t Block the Event Loop for measuring loop delay. |
Permissions
When the permission model is enabled, spawning a process and creating a worker are denied unless you pass --allow-child-process and
--allow-worker, so an application that uses this page’s techniques needs those flags. The permission model is Stability 2 (Stable) since
v23.5.0 and v22.13.0, but both of those flags, like --allow-net, are Stability 1.1 (Active development); since v24.4.0 and v22.18.0 the granted
flags are inherited by child Node.js processes through NODE_OPTIONS. It is a guard against accidents, not a sandbox for hostile code; see
Permission Model.
Source: Node.js API: Permissions
What changed recently
| Version | Change |
|---|---|
v16.0.0 |
|
v18.14.0, v19.4.0 |
|
v20.0.0 |
The permission model arrived, with |
v24.0.0 |
DEP0190 became a runtime deprecation: an argument array together with the |
v26.9 |
The web |
Between Node.js 22, 24 and 26 the three modules behave the same for everything on this page; only the version-specific rows above differ.
Where the books are outdated
Efficient Node.js (Samer Buna, O’Reilly, 2025) devotes a chapter to scaling Node.js applications with the cloning, decomposing and splitting
strategies, and it already uses isPrimary and os.availableParallelism(), so its cluster material matches current Node.js. Check three things
when you apply it. First, it treats cluster as the main implementation of cloning; on current deployments the orchestrator-managed replicas
described above are often the default, with cluster kept for single-host setups. Second, hand-written signal-based restarts are better replaced
by a process manager or the rolling updates of the platform. Third, older tutorials, which that book is not one of, still use cluster.isMaster
and repeat the claim that cluster relies on SO_REUSEPORT; the official guide corrects this, since the primary distributes connections itself by
default. Node.js Projects (Jonathan Wexler, O’Reilly, 2025) concentrates on building applications and does not cover the concurrency modules, so
use this page and the linked deep dives for them.