Worker Threads & Cluster
Reviewed & published by Brayan K
Worker threads let Node run CPU-heavy JavaScript on separate threads without blocking the main event loop, while the cluster module forks multiple processes to scale a server across all your CPU cores.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll know when CPU-bound work needs a worker thread, how to pass data with workerData and message with parentPort, and how cluster differs from both worker_threads and child_process for scaling real applications.
What You'll Learn in This Lesson
- When CPU-bound work needs a worker thread (and when it doesn't)
- How to create a Worker and pass input with workerData
- How to message both ways with parentPort and postMessage
- How worker_threads differ from child_process
- How cluster forks processes to scale a server across cores
- Which tool to choose: threads, processes, or cluster
1️⃣ Your First Worker Thread
A worker is a separate JavaScript file that runs on its own thread. You create one with new Worker(path, options), hand it input through the workerData option, and get a result back when it calls parentPort.postMessage(...). Wrapping the worker in a Promise makes it easy to await.
The worker below does a CPU-bound loop (summing a million numbers). Crucially, the main thread stays free the whole time — that's the entire point of using a thread. You need two files placed next to each other.
// heavy-worker.mjs — runs on its OWN thread, never blocking the main one.
import { workerData, parentPort } from 'node:worker_threads';
// A deliberately CPU-bound task: sum 0..n.
let total = 0;
for (let i = 0; i <= workerData.upTo; i++) {
total += i;
}
// Send the result back to the main thread.
parentPort.postMessage(total);// main.mjs
import { Worker } from 'node:worker_threads';
function runWorker(upTo) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./heavy-worker.mjs', import.meta.url), {
workerData: { upTo }, // input data the worker can read
});
worker.on('message', resolve); // result comes back here
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
console.log('main: starting worker...');
const sum = await runWorker(1_000_000);
console.log('main: worker returned', sum);
console.log('main: still responsive the whole time');2️⃣ Two-Way Messaging
Beyond a one-shot result, a worker can act like a little service you talk to repeatedly. Both sides have a port: the worker uses parentPort.on('message', ...) and parentPort.postMessage(...); the main thread uses worker.on('message', ...) and worker.postMessage(...).
When you're finished, call worker.terminate() so the thread shuts down and your program can exit cleanly.
// echo-worker.mjs
import { parentPort } from 'node:worker_threads';
// Listen for messages from the main thread and reply.
parentPort.on('message', (msg) => {
parentPort.postMessage(`worker got: ${msg.toUpperCase()}`);
});// main.mjs
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./echo-worker.mjs', import.meta.url));
worker.on('message', (reply) => {
console.log(reply);
worker.terminate(); // shut the worker down when we're done
});
worker.postMessage('hello');3️⃣ cluster — Scaling a Server
Worker threads keep one heavy task from freezing the loop. cluster solves a different problem: a single Node process uses one core, so on an 8-core machine you'd waste 7. cluster forks one Node process per core, and they all share the same listening port — the operating system spreads incoming connections across them.
The example below shows the shape: the primary forks a worker per core and restarts any that die, while each worker runs an identical HTTP server. The PIDs will differ every run, so treat the output as representative.
const cluster = require('node:cluster');
const os = require('node:os');
if (cluster.isPrimary) {
// The PRIMARY process forks one worker per CPU core.
console.log(`Primary ${process.pid} is running`);
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork(); // each fork is a full Node process
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} died — restarting`);
cluster.fork(); // keep the pool full
});
} else {
// Every WORKER runs this and shares the same listening port.
require('node:http')
.createServer((req, res) => res.end(`handled by ${process.pid}`))
.listen(3000);
console.log(`Worker ${process.pid} ready`);
}Your turn. The program below works once you fill in the two blanks marked ___. Follow the 👉 hints, write the tiny worker file shown in the comment, then run it.
// square-worker.mjs
// import { workerData, parentPort } from 'node:worker_threads';
// parentPort.postMessage(workerData.n * workerData.n);
// main.mjs — fill each ___ then run it.
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./square-worker.mjs', import.meta.url), {
___: { n: 12 }, // 👉 the option that passes input: workerData
});
worker.on('message', (result) => {
console.log('12 squared is', ___); // 👉 result
});
// ✅ Expected output:
// 12 squared is 144Mini-Challenge: Factorial on a Thread
No blanks this time — just a brief and an outline. Build the two files, run them, and check your output against the example in the comments. This ties together workerData, parentPort, and a Promise wrapper.
// 🎯 MINI-CHALLENGE: factorial in a worker thread
//
// File 1 — fact-worker.mjs:
// import { workerData, parentPort } from 'node:worker_threads';
// let f = 1n;
// for (let i = 2n; i <= BigInt(workerData.n); i++) f *= i;
// parentPort.postMessage(f.toString());
//
// File 2 — main.mjs:
// - Wrap the Worker in a Promise that resolves on 'message'.
// - Run it for n = 20 and log "20! = <result>".
// (Pass { workerData: { n } } and listen for the message.)
//
// ✅ Example output:
// 20! = 2432902008176640000
// write both files, then run: node main.mjsCommon Errors (and the fix)
- ❌ Using a worker for I/O — Threads don't speed up database or network calls. ✅ Reserve workers for CPU-bound work; let async/await handle I/O.
- ❌ "Cannot find module" for the worker file — A bare relative path can resolve from the wrong place. ✅ Use new URL('./worker.mjs', import.meta.url).
- ❌ Program never exits — A worker is still running. ✅ Call worker.terminate() (or let the worker finish and exit) when done.
- ❌ "could not be cloned" sending a message — You tried to pass a function or class instance. ✅ Send plain data; messages are structured-cloned.
- ❌ Confusing cluster with threads — cluster scales request throughput; it won't unblock one heavy calculation. ✅ Use worker_threads for that.
Pro Tips
- 💡 Pool your workers: creating a thread per task is wasteful. Reuse a small fixed pool of workers, or use a library like piscina.
- 💡 Share big numeric data with SharedArrayBuffer to avoid copying it into each worker.
- 💡 In production, a process manager like PM2 often replaces hand-rolled cluster code — same idea, less boilerplate.
📋 Quick Reference — Pick the Right Tool
| Need | Reach for |
|---|---|
| Heavy CPU calc, same process | worker_threads |
| Run an external program | child_process (spawn/exec) |
| Another Node script + IPC | child_process.fork |
| Scale a server across cores | cluster |
🎉 Lesson Complete!
- ✅ worker_threads run CPU-bound JavaScript off the main thread
- ✅ Pass input with workerData; return results with parentPort.postMessage
- ✅ Message both ways with postMessage and on('message'); finish with terminate()
- ✅ Threads share a process; child_process uses separate processes for isolation or external tools
- ✅ cluster forks one process per core to scale a server's throughput
Practice quiz
When should you reach for worker_threads?
- For database and network I/O
- For CPU-bound work that would block the main thread
- To make HTTP requests faster
- For logging
Answer: For CPU-bound work that would block the main thread. Workers are for heavy computation; async/await already handles I/O efficiently.
Which import gives you the Worker class?
- node:worker_threads
- node:cluster
- node:child_process
- node:threads
Answer: node:worker_threads. import { Worker } from 'node:worker_threads' creates worker threads.
How do you pass initial input into a worker at creation?
- As a global variable
- Via process.argv
- Through the workerData option
- By writing a file
Answer: Through the workerData option. The workerData option is copied into the worker, readable via imported workerData.
How does a worker send a result back to the main thread?
- return value
- console.log
- global.result =
- parentPort.postMessage(...)
Answer: parentPort.postMessage(...). parentPort.postMessage(data) sends a message the main thread receives on 'message'.
How are messages between main and worker transferred?
- By structured clone (copied)
- By shared reference
- As JSON strings only
- They aren't — workers can't message
Answer: By structured clone (copied). Messages are structured-cloned, so plain objects/buffers travel but functions don't.
What is the difference between worker_threads and child_process?
- They are identical
- worker_threads share a process; child_process spawns separate OS processes
- child_process is faster for CPU work
- worker_threads can't run JavaScript
Answer: worker_threads share a process; child_process spawns separate OS processes. Threads are in-process and lighter; child processes are isolated, separate processes.
What does the cluster module do?
- Encrypts traffic
- Validates requests
- Forks one process per core to scale a server across cores
- Replaces async/await
Answer: Forks one process per core to scale a server across cores. cluster forks a worker process per CPU core, all sharing the listening port.
How do you shut a worker down when you are done with it?
- worker.stop()
- worker.kill()
- delete worker
- worker.terminate()
Answer: worker.terminate(). worker.terminate() ends the thread so your program can exit cleanly.
What reliably resolves the worker file path in an ES module?
- new URL('./worker.mjs', import.meta.url)
- './worker.mjs'
- __dirname + '/worker.mjs'
- process.cwd()
Answer: new URL('./worker.mjs', import.meta.url). A bare relative path can resolve wrongly; new URL with import.meta.url is safe.
What does cluster NOT solve?
- Scaling request throughput across cores
- Restarting workers that die
- Unblocking one heavy calculation on the main thread
- Sharing a listening port
Answer: Unblocking one heavy calculation on the main thread. cluster scales throughput; use worker_threads to offload one heavy calculation.
Continue this course
- Previous: Child Processes (exec & spawn)
- Next: The crypto Module — Hash, HMAC, random bytes/UUIDs, and password KDFs with node:crypto
- Quick reference: Node.js cheat sheet
Frequently asked questions
When should I use worker_threads instead of just async code?
Use worker_threads only for CPU-bound work — heavy computation like image processing, encryption over big data, parsing huge files, or number crunching that would otherwise block the single main thread. Regular async/await and the event loop already handle I/O-bound work (database calls, network requests, file reads) efficiently, so workers add no benefit there and bring overhead. The signal you need a worker is that one synchronous calculation makes your whole app unresponsive; move that calculation onto a thread so the main loop stays free.
What is the difference between worker_threads and child_process?
worker_threads run inside the same process, sharing memory via mechanisms like SharedArrayBuffer and communicating with fast message passing — they're lighter weight and ideal for CPU-bound JavaScript work. child_process (fork/spawn/exec) starts entirely separate OS processes, each with its own memory; that's heavier but gives full isolation and lets you run non-Node programs. Rule of thumb: threads for in-process CPU work in Node, child processes for running external tools or when you want strong isolation between tasks.
How is cluster different from worker_threads?
cluster is about scaling a SERVER across CPU cores, not about offloading one calculation. The primary process forks several worker processes that all listen on the same port; the operating system load-balances incoming connections across them, so a multi-core machine can handle far more concurrent requests. worker_threads, by contrast, are about moving a CPU-heavy computation off the main thread within a single process. Use cluster (or a process manager) to scale throughput; use worker_threads to keep one heavy task from freezing the event loop.
How do the main thread and a worker exchange data?
Input is passed once at creation through the workerData option, which the worker reads from the imported workerData. For ongoing communication, both sides use a port: the worker uses parentPort.postMessage(...) and parentPort.on('message', ...), and the main thread uses worker.postMessage(...) and worker.on('message', ...). Messages are structured-cloned (copied), so plain objects, arrays, buffers, and typed arrays travel fine; functions and class instances do not. For zero-copy sharing of large numeric data, use a SharedArrayBuffer.