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

Table 1. The options at a glance
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.

node:worker_threads

A thread with its own V8 isolate and event loop inside the same process

Isolated heap; can share a SharedArrayBuffer

postMessage(), MessageChannel, shared memory

CPU-bound JavaScript that would stall the loop: hashing, parsing, compression, image work.

node:child_process

A separate operating-system process, Node.js or not

Fully isolated

stdio, or an IPC channel for fork()

Running another program, or a Node.js script you want isolated from the parent.

node:cluster

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.

flowchart TD A["A request or job is slow, or you need more throughput"] --> B{"Is it waiting on I/O?"} B -- "Yes" --> C["Stay on the event loop: use async APIs, add concurrency limits"] B -- "No, it burns CPU" --> D{"Is it another program or another language?"} D -- "Yes" --> E["child_process: spawn or execFile with an argument array"] D -- "No, it is JavaScript" --> F{"Does one call block the loop for more than a few milliseconds?"} F -- "Yes" --> G["worker_threads: a pool of long-lived workers"] F -- "No, I need more requests per second" --> H{"One machine or many?"} H -- "One machine, several cores" --> I["cluster, or one container per core"] H -- "Many machines, or rolling updates" --> J["Multiple containers behind a load balancer"] I --> K{"Is state shared between requests?"} J --> K K -- "Yes" --> L["Move state out of the process: database, cache, queue; or split by key"] K -- "No" --> M["Clone freely"]
Figure 1. Choosing a concurrency model

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.

A heartbeat shows whether the loop was blocked (lag.js)
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.

Observed output
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.

execFile with an argument array, and fork with IPC (multi.js)
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):

Observed output
node says v26.10.0
child answered { id: 1, square: 144 }

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.

A primary that forks workers, restarts crashed ones and shuts down gracefully (cluster-demo.js)
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):

Observed output
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.

Four replicas of one process each, with a resource limit (compose.yaml)
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.

Table 2. The three scaling strategies and where Node.js features fit
Strategy Idea Node.js and platform tools Main trade-off

Cloning

Run identical copies of the whole application and spread the load across them.

cluster on one host; replicas behind a load balancer across hosts; worker threads inside each copy.

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.

Splitting by key across four worker threads (shard.js)
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:

Observed output
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

Table 3. Pitfalls when choosing a model
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 exec() with user input, or capturing large output with it

Use execFile() or spawn() with an argument array and stream the output.

Respawning every exited cluster worker

Check worker.exitedAfterDisconnect so a deliberate shutdown ends instead of looping.

Choosing a worker count from the host’s core count inside a limited container

Make the count a setting and verify what os.availableParallelism() returns where the code runs.

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.

What changed recently

Table 4. Changes relevant to concurrency, with versions
Version Change

v16.0.0

cluster.isPrimary and cluster.setupPrimary() were added; cluster.isMaster and cluster.setupMaster() are deprecated aliases (Stability 0).

v18.14.0, v19.4.0

os.availableParallelism() was added as the replacement for counting os.cpus() entries.

v20.0.0

The permission model arrived, with --allow-worker and --allow-child-process (both still Active development); it became Stable in v23.5.0 and v22.13.0.

v24.0.0

DEP0190 became a runtime deprecation: an argument array together with the shell option in child_process.

v26.9

The web Worker global appeared behind --experimental-web-worker (Experimental). The node:worker_threads module itself is unchanged and Stable.

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.