Multithreading in Node.js
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
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.
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.
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.
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.
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 });
});const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData));How to Explain it in an Interview
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.
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.
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
Whether you understand these are different claims — the call stack is single-threaded, the process is not.
Recognizing cluster (scale I/O across cores), worker_threads (parallelize CPU work), and child_process (full isolation) solve different problems, not interchangeable ones.
Knowing that worker_threads don't share memory by default — SharedArrayBuffer plus Atomics is a deliberate, narrow exception, not the default behavior.
Understanding that spawning a thread or process isn't free — pooling workers instead of creating them per-request is a real production pattern.
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:
postMessage(), which structured-clones the payload (a deep copy, not a reference) into the receiving isolate.- Transferable objects (like
ArrayBuffer), which move ownership instead of copying — the sender loses access after transfer. SharedArrayBuffercombined withAtomics, 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.