RezloRezloPrep
Sign Up Free
Performance & ScalingParallelism & CPU-Bound Work

Multithreading in Node.js

12 min readAdvancedUpdated for Node.js 20 LTS
Node.js itself is single-threaded, but the Node.js process is not — worker_threads, cluster, and child_process each hand CPU-bound or isolation-sensitive work to real OS threads or processes without ever blocking the event loop.

Is Node.js really single-threaded?

Only your JavaScript is. The V8 call stack that runs your application code is single-threaded, which is what makes the Event Loop model work without locks or race conditions in userland. But the Node.js process backing it is not: libuv already runs a background threadpool (4 threads by default) for filesystem, DNS, and crypto calls, and the runtime exposes three separate ways to run your own code across real threads or processes when you need actual parallelism.

Conflating "Node is single-threaded" with "Node can't use multiple cores" is the single most common mistake candidates make on this topic — the process can absolutely use every core on the machine, just not by default and not inside the main event loop.

The three concurrency primitives

Scale I/O Across Cores

cluster

Forks N copies of your entire process (one per CPU core), each with its own event loop and V8 heap. The primary process load-balances incoming connections across workers. Best for scaling stateless HTTP servers.

SharesServer port only
Run JS in Parallel

worker_threads

Spawns real OS threads inside the same process, each with its own V8 isolate and event loop. Communicates via structured-clone message passing (or SharedArrayBuffer for true shared memory). Best for CPU-bound work like image processing or hashing.

SharesNothing, by default
Full Isolation

child_process

Spawns an entirely separate OS process, optionally running a different executable (a Python script, a CLI tool). Highest overhead and full isolation — no shared memory, no shared file descriptors unless piped explicitly.

SharesNothing (separate process)

Code Example: Offloading CPU Work to a Worker Thread

Blocking the main thread with a synchronous, CPU-heavy computation — like a naive recursive Fibonacci — freezes every other request the server is handling. Moving it into a worker_threads instance keeps the event loop free to keep serving other clients while the computation runs on a separate thread.

main.js
JavaScript
const { Worker } = require('worker_threads');

function runInWorker(n) {
return new Promise((resolve, reject) => {
  const worker = new Worker('./fib-worker.js', { workerData: n });
  worker.once('message', resolve);
  worker.once('error', reject);
});
}

app.get('/fib/:n', async (req, res) => {
const result = await runInWorker(Number(req.params.n));
res.json({ result });
});
fib-worker.js
JavaScript
const { workerData, parentPort } = require('worker_threads');

function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData));
Why This Matters Under Load
Without a worker→ fib(40) blocks the event loop for ~1s — every other request queues behind it
With a worker→ fib(40) runs on its own OS thread — the event loop keeps dispatching other requests
worker.postMessage(n)→ structured-clones the argument into the worker's isolate, no shared memory
parentPort.postMessage(result)→ clones the result back — cheap for primitives, expensive for large objects

How to Explain it in an Interview

The 30-Second Spoken Pitch
"Node.js's own JavaScript execution is single-threaded, but the process isn't limited to one core. For scaling I/O-bound HTTP servers across CPUs, I'd reach for the cluster module or a process manager like PM2 in cluster mode — each worker gets its own event loop and the OS load-balances connections between them. For CPU-bound work that would otherwise block the event loop — image resizing, hashing, heavy parsing — I'd use worker_threads, which run real OS threads inside the same process and communicate via message passing, or SharedArrayBuffer when I specifically need shared memory. child_process is for the cases needing full isolation, like shelling out to a different executable entirely."
If the Interviewer Probes Deeper:
1. Message-Passing Cost

Every postMessage() structured-clones its payload by default. Passing large buffers between threads repeatedly can itself become the bottleneck — Transferable objects (like ArrayBuffer) avoid the copy by transferring ownership instead.

2. Worker Pool Sizing

Spawning a new worker per request is expensive (V8 isolate startup cost). Production code pools a fixed number of long-lived workers (roughly os.cpus().length) and queues work to them, rather than spawning per-request.

3. cluster vs worker_threads

cluster duplicates the whole process (separate V8 heaps, separate event loops) and is for scaling network I/O across cores. worker_threads share a process and are for parallelizing a specific CPU-bound computation. Using cluster for CPU work wastes memory; using worker_threads to scale a whole HTTP server is unusual.

What the Interviewer is Testing

Single-threaded JS vs multi-threaded process

Whether you understand these are different claims — the call stack is single-threaded, the process is not.

Choosing the right primitive

Recognizing cluster (scale I/O across cores), worker_threads (parallelize CPU work), and child_process (full isolation) solve different problems, not interchangeable ones.

Shared memory is opt-in

Knowing that worker_threads don't share memory by default — SharedArrayBuffer plus Atomics is a deliberate, narrow exception, not the default behavior.

Overhead awareness

Understanding that spawning a thread or process isn't free — pooling workers instead of creating them per-request is a real production pattern.

Common Mistake: Assuming Worker Threads Share Memory Like Real Threads

Candidates frequently assume worker_threads behave like threads in Java or C++, with direct access to shared variables. By default, each worker has its own V8 isolate and heap — nothing is shared. Communication happens by:

  1. postMessage(), which structured-clones the payload (a deep copy, not a reference) into the receiving isolate.
  2. Transferable objects (like ArrayBuffer), which move ownership instead of copying — the sender loses access after transfer.
  3. SharedArrayBuffer combined with Atomics, the one deliberate exception that gives true shared, mutable memory across threads — and reintroduces the exact race-condition risks Node's single-threaded model was designed to avoid.

Follow-up Questions