---
title: 'Cluster and Worker Threads'
source: 'https://academia.sh/en/courses/nodejs/cluster-and-worker-threads'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:51+00:00'
license: 'CC BY-SA 4.0'
---

# Cluster and Worker Threads

A process cluster sharing the same port, a worker thread's memory model, which kind of work each approach fits, and the measured difference in blocking.

The previous lesson covered starting a child process and stated its cost: each
process carries its own memory and its own runtime instance, and every message
between them is copied.

This lesson examines two special forms of it. The first is a cluster of processes
sharing the same port, and its purpose is genuinely handling multiple requests at
once on a multi-core machine. The second is threads running inside the same process
that can share memory; their purpose is moving an expensive computation off the main
thread.

## Why One Process Is Not Enough

The limit measured in the event loop lesson becomes binding here. A single process
handles requests on a single thread running on a single core. For an
I/O-heavy service this is enough: the process spends most of its time waiting in the
poll phase, and one core is enough for dozens of concurrent connections.

The picture changes once part of the work is CPU-bound. How many independent
execution units the machine has can be asked:

```js
// cores.mjs
import os from 'node:os';
console.log('available parallelism:', os.availableParallelism());
```

```sh
node cores.mjs
```

```
available parallelism: 12
```

This number varies by machine and by the limits granted to the process; it was
taken on a sample run. A single process uses only one of these units.

## A Process Cluster

The `node:cluster` module sets up a primary process and several worker processes.
Workers are separate processes forked by the primary; they share the **same**
listening socket.

```js
// cluster.mjs
import cluster from 'node:cluster';
import { createServer } from 'node:http';

const WORKER_COUNT = 2;

if (cluster.isPrimary) {
  console.log(`primary process ${process.pid}, starting ${WORKER_COUNT} workers`);
  for (let i = 0; i < WORKER_COUNT; i += 1) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} exited (code ${code}, signal ${signal}); starting a replacement`);
    cluster.fork();
  });
} else {
  createServer((request, response) => {
    response.writeHead(200, { 'content-type': 'text/plain' });
    response.end(`request handled by worker: ${process.pid}\n`);
  }).listen(8791, '127.0.0.1', () => {
    console.log(`worker ${process.pid} listening`);
  });
}
```

Running the server in one terminal and sending four requests from another:

```sh
node cluster.mjs
```

```sh
for i in 1 2 3 4; do curl -s http://127.0.0.1:8791/; done
```

```
primary process 81263, starting 2 workers
worker 81265 listening
worker 81266 listening
request handled by worker: 81265
request handled by worker: 81266
request handled by worker: 81265
request handled by worker: 81266
```

Process ids change on every run; the order in which the two workers start
listening also changes. What is fixed is that requests get handled by two different
processes — an even, alternating distribution like the example's is not guaranteed.

Two processes being able to listen on the same port rests on the listening socket
concept introduced in the How the Internet Works course. The primary process opens
the socket once and hands the descriptor down to the workers; an incoming connection
lands on one of the processes sharing that descriptor. There is one port number;
there are multiple processes sharing it.

The callback attached to the `exit` event puts a replacement in place of a worker
that goes down. This is the cluster's real gain in terms of robustness: a fatal error
in one worker does not stop the service entirely. The restart rate has to be limited
so it does not enter an infinite loop; if a worker crashes immediately every time it
starts, opening a new one just hides the failure.

The cluster's fundamental constraint is that **memory is not shared** between
workers. The measurement collector's bucket map accumulates separately in each
worker, and the `/summary` response varies depending on which worker handled the
request. Clustering a stateful service requires moving its state out of the process
— to a shared store.

## Worker Threads

The `node:worker_threads` module starts a separate thread within the same process.
Each thread has its own event loop and its own heap; memory is not shared by default
but can be explicitly shared through a shareable buffer.

An expensive computation is handed to a thread below:

```js
// heavy-work.mjs
import { parentPort, workerData } from 'node:worker_threads';

// A deliberately expensive computation on top of measurement values
function heavySum(iterations) {
  let total = 0;
  for (let i = 0; i < iterations; i += 1) total += Math.sqrt(i) % 1;
  return total;
}

parentPort.postMessage(Number(heavySum(workerData.iterations).toFixed(3)));
```

```js
// worker-main.mjs
import { Worker } from 'node:worker_threads';
import { once } from 'node:events';

const ITERATIONS = 3e8;

setTimeout(() => console.log('2 main thread free: timer ran'), 0);
console.log('1 worker started');

const worker = new Worker('./heavy-work.mjs', { workerData: { iterations: ITERATIONS } });
const [result] = await once(worker, 'message');
console.log('3 result from worker:', result);
await once(worker, 'exit');
```

```sh
node worker-main.mjs
```

```
1 worker started
2 main thread free: timer ran
3 result from worker: 149989897.293
```

When the same computation is done on the main thread, the order changes:

```js
// no-worker.mjs
const ITERATIONS = 3e8;

function heavySum(iterations) {
  let total = 0;
  for (let i = 0; i < iterations; i += 1) total += Math.sqrt(i) % 1;
  return total;
}

setTimeout(() => console.log('3 timer only ran now'), 0);
console.log('1 computation starting');
console.log('2 result:', Number(heavySum(ITERATIONS).toFixed(3)));
```

```sh
node no-worker.mjs
```

```
1 computation starting
2 result: 149989897.293
3 timer only ran now
```

The difference between the two outputs is the measurement itself. In the first, the
timer ran while the computation was in progress; the main thread was free and could
have handled an incoming request at that moment. In the second, the timer waited for
the computation to finish — on a server, this means every concurrent request waits.

The result value comes out the same in both runs; the computation is deterministic
and moving it to a thread does not change the result.

## Which One, When

The two approaches do not substitute for each other.

| Criterion | Process cluster | Worker thread |
|---|---|---|
| Isolation | complete; if one crashes, the other continues | weak; a fatal error can bring down the process |
| Memory | separate; each process carries its own instance | shared process, separate heap; a shareable buffer is possible |
| Startup cost | high | low |
| Message cost | serialization + inter-process copy | structured copy; large data can be transferred |
| Suited work | multiplying request-handling capacity | moving a single expensive computation off the main path |

The rule for the measurement collector is this: a cluster is used when request
handling capacity needs to grow, a worker thread when an expensive computation
inside a single request needs to come off the main thread. The two can also be used
together — each cluster worker can keep its own thread pool.

Starting a thread has a cost too: a new execution environment is set up and modules
are re-evaluated. Opening a thread per request can end up costlier than the
computation itself. The common solution is keeping a few pre-opened threads in a
pool and distributing jobs to them — the same logic the file system calls' pool
uses.

When shared memory is needed, a buffer can be given to both threads through a
`SharedArrayBuffer`. In that case race conditions appear, and access is coordinated
with `Atomics` operations; every concurrency problem the single-threaded model
removed comes back in through this door. Use sharing only when measurement shows it
is needed.

## Summary

- A single process uses a single core; this is enough for I/O-heavy work, not for
  CPU-bound work.
- A cluster sets up processes sharing the same listening socket; there is one port,
  multiple processes share it.
- Cluster workers do not share memory; a stateful service has to move its state out
  of the process before being clustered.
- A worker thread runs a separate event loop in the same process; once the
  expensive computation moves there, the main thread keeps accepting requests.
- The selection axis is isolation versus sharing: a cluster to multiply capacity, a
  thread to take a single computation off the main path.

## Next Step

The built-in modules topic is complete: file, path, stream, buffer, event, HTTP,
child process, and multi-core usage. What comes after this is about turning these
pieces into a working application. The first step is giving the measurement
collector a command-line face: parsing arguments, reading from a pipeline, and
writing usage text to the correct stream.
