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

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 144

Mini-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.mjs

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Pick the Right Tool

NeedReach for
Heavy CPU calc, same processworker_threads
Run an external programchild_process (spawn/exec)
Another Node script + IPCchild_process.fork
Scale a server across corescluster

🎉 Lesson Complete!

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

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.