Lesson 12 / 20
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.
Contents
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:
// cores.mjs import os from 'node:os'; console.log('available parallelism:', os.availableParallelism());
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.
// 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:
node cluster.mjs
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:
// 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)));
// 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');
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:
// 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)));
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.