Everything in How Node.js Works and The Node.js Event Loop describes a single JavaScript thread inside a single OS process.
That model is fast for I/O, but it has no answer for CPU-bound work, for running another program, or for using more than one CPU core.
This page is about the three ways Node lets you step outside that single thread - child_process, worker_threads, and cluster - and the shared mental model that makes their differences predictable instead of arbitrary.
Processes Basics walks through working code for each of them; child_process, worker_threads, and cluster Module go deep on the syntax and production patterns for each one individually.
Here, the goal is the map: what actually changes - memory, startup cost, fault isolation - as you move from staying in-process to spawning a full new one.
Node's concurrency escape hatches sit on one spectrum from "share everything" (staying in-process) to "share nothing" (a separate OS process), and each option trades isolation for cost in a specific, predictable way.
Insight: Reaching for the wrong primitive - a new process for a task that only needed a thread, or a thread pool for a task that needed process-level fault isolation - either wastes resources or lets one failure take down more than it should.
When to Use This Model: Deciding whether CPU-bound work belongs on a worker thread, whether a task needs a full child process, whether cluster is the right way to use multiple cores, and reading the per-primitive pages that follow with the right frame already in place.
Limitations/Trade-offs: None of these primitives are free - processes cost memory and startup time, threads cost serialization on every message, and both add real operational surface (more things that can crash, more things to monitor).
Related Topics: the event loop, child_process, worker_threads, the cluster module, graceful shutdown.
An OS process is the operating system's unit of isolation: its own memory space, its own file descriptors, its own crash domain.
When one process crashes, the operating system guarantees it cannot corrupt another process's memory - that guarantee is exactly what you're buying when you spawn one.
A thread, by contrast, is a unit of execution that runs inside a process and, in most languages, shares that process's memory directly with every other thread in it.
Node's worker_threads bend that second definition slightly: each worker gets its own V8 isolate - its own JavaScript heap, its own garbage collector, its own event loop - even though all of them live inside one OS process.
That's why workers feel closer to "processes" in behavior (isolated JS state, message passing instead of shared variables) while remaining cheaper than a true OS process to spawn.
Sharing memory between workers is possible, but only deliberately, through a SharedArrayBuffer and Atomics - the default is copying, not sharing.
A useful analogy: staying in-process is one clerk handling every request at a single desk.
Spawning a child_process is opening a second, fully independent office across town - nothing is shared, and if it burns down, your office is unaffected, but every errand there costs a trip.
A worker_threads worker is more like bringing a second clerk into the same building, in a separate room - they can pass notes under the door (messages), and only see each other's desk if you explicitly wire up a shared whiteboard (SharedArrayBuffer).
// Same task, three different isolation levelsnew Worker('./task.js', { workerData }); // shared process, separate V8 isolatespawn('some-binary', ['--flag']); // separate OS process, no shared memorycluster.fork(); // separate OS process, shared listening socket
child_process spawns a genuinely separate OS process - it can run any executable, not just Node scripts, and it shares nothing with the parent by default.
Communication happens over stdio pipes, or over a dedicated IPC channel when you use fork() specifically (which starts a Node child and wires up process.send()/message events for you).
This is the heaviest option in startup cost and memory, and it's also the strongest isolation: a crash in the child process cannot corrupt the parent's memory.
worker_threads creates a new thread inside the same process, with its own isolated V8 heap but shared OS-level resources like file descriptors.
Startup is meaningfully cheaper than spawning a process, and workerData clones a startup payload once; ongoing communication through postMessage uses the structured clone algorithm, which copies data rather than sharing it.
An uncaught exception inside a worker terminates that worker specifically - the main thread and any other workers keep running, provided something is listening for the worker's 'error' event to observe what happened.
cluster is not a fourth, independent primitive - mechanically, it's child_process.fork() under the hood, wrapped with logic that lets multiple forked Node processes share one listening socket.
The operating system (or Node's own round-robin logic, depending on platform) distributes incoming connections across those processes, which is how a single-threaded runtime uses more than one CPU core for one HTTP server.
Because each cluster worker is a full separate process, none of them share in-memory state - a cache warmed in one worker is invisible to the others, which is a common source of confusion for anyone assuming cluster behaves like a thread pool.
The practical consequence of all this: worker_threads is for CPU-bound JavaScript that needs to get off the main thread without process-spawn overhead; child_process is for running other programs or for isolation strong enough that one crash truly cannot touch the rest; cluster is for using multiple cores to serve one kind of network-facing work, at the cost of duplicated memory and no shared cache.
Fault isolation is the axis most teams underweight until an incident forces the question.
A worker thread crash is contained to that worker, but it still shares the OS process with everything else - a severe enough native-code failure inside a worker (a segfault in a native addon, for instance) can still bring down the whole process, workers included.
A child process crash is contained even from that: the operating system's process boundary is a much harder guarantee than V8's isolate boundary.
Scaling with cluster is a legitimate pattern on a single multi-core host, but it competes with a different, increasingly common answer to the same problem: scaling replicas horizontally under an orchestrator like Kubernetes.
Both approaches solve "use more than one core," but they solve it at different layers - cluster inside one Node deployment, orchestration across many single-threaded deployments - and running both at once usually just adds complexity without adding capacity.
Security and observability considerations differ sharply by primitive.
child_process with shell: true and unsanitized input is a direct command-injection vector - passing an argument array to spawn instead of a shell string avoids that class of bug entirely.
Every primitive here also multiplies your monitoring surface: a PID per child process, a threadId per worker, and per-worker CPU/memory tracking are all necessary once you're not looking at a single event loop anymore.
The ecosystem has converged on worker pools as the practical unit of adoption for CPU-bound work, rather than spawning workers ad hoc per request - libraries like piscina manage a fixed pool, queueing, and lifecycle so application code doesn't reimplement scheduling.
Approach
Strength
Weakness
Best Fit
Stay in-process (async/await)
Zero overhead, simplest mental model
No help at all for CPU-bound work
I/O-bound handlers, the overwhelming default
worker_threads
Cheap to spawn, isolated crashes, optional shared memory
Serialization cost per message unless using SharedArrayBuffer
CPU-bound JS: hashing, image processing, large data transforms
child_process
Strongest isolation, can run non-Node binaries
Highest spawn cost, no shared memory at all
Running external tools, isolating genuinely untrusted or fragile work
cluster
Uses multiple cores for one HTTP listener
Duplicated memory per worker, no shared in-process cache
Single multi-core host serving one HTTP-bound service
"Node is single-threaded, so it can't use multiple CPU cores." The main JavaScript thread is single-threaded, but worker_threads, child_process, and cluster all exist specifically to use more than one core when the work justifies it.
"worker_threads and child_process are the same thing with different names." They differ in isolation level and cost - workers share a process and default to copying messages; child processes are fully separate, with no shared memory at all.
"cluster gives workers shared memory." Cluster workers are separate OS processes; nothing is shared automatically, and any state a worker needs in common with its siblings has to live somewhere external, like Redis or a database.
"Spawning a worker per request is how you parallelize CPU work." That recreates the exact overhead problem workers are meant to avoid - production code uses a fixed-size pool, reused across requests.
"A crashed worker or child process always takes down the whole app." Isolation is the point - a worker's uncaught exception terminates that worker, and a child process's crash stays within the OS process boundary, as long as something is watching for the failure event.
What's the simplest way to decide between staying in-process, a worker thread, and a child process?
Ask what kind of work it is first: pure I/O stays in-process, CPU-bound JavaScript goes to a worker_threads pool, and running another program (or needing hard process-level isolation) goes to child_process.
Is `cluster` a different underlying mechanism from `child_process`?
No - cluster is built on child_process.fork(), with added logic for sharing a listening socket across the forked processes so one HTTP server can use multiple CPU cores.
Do worker threads share memory with the main thread by default?
No - postMessage and workerData copy data using the structured clone algorithm; sharing actual memory requires explicitly using a SharedArrayBuffer with Atomics.
Why is spawning a `worker_threads` worker cheaper than spawning a `child_process`?
A worker stays inside the same OS process and only creates a new V8 isolate and thread, while a child process asks the operating system for an entirely new process with its own memory space and file descriptors - a much heavier operation.
Can a crash in one cluster worker take down the whole server?
Not by default - each cluster worker is a separate OS process, so one crashing doesn't corrupt the others, though the primary process should be written to detect the exit and fork a replacement.
What happens if a worker thread throws an uncaught exception?
That specific worker terminates; the main thread and any sibling workers keep running, provided the code that created the worker is listening for its 'error' event.
When is `cluster` the wrong tool even though it "uses more cores"?
When you're already running multiple replicas under an orchestrator like Kubernetes - combining pod-level horizontal scaling with in-process cluster forking usually adds operational complexity without adding real capacity.
Why does `spawn('cmd', args)` matter more than `exec('cmd ' + args)` for security?
spawn with an argument array passes arguments directly to the OS without going through a shell, so user-controlled input in an argument can't be interpreted as shell syntax - exec (or spawn with shell: true) opens that door.
Do I need a worker pool library, or can I manage workers myself?
You can manage a small fixed pool yourself, but libraries like piscina handle queueing, backpressure, and lifecycle correctly - reimplementing that from scratch is easy to get subtly wrong under load.
Does `worker_threads` help if my bottleneck is actually I/O, not CPU?
No - I/O-bound work (network calls, most database queries) already yields the event loop via await and gets no benefit from a separate thread; workers only help when the work is genuinely CPU-bound JavaScript.
What's the difference between `child_process.exec` and `child_process.fork`?
exec runs an arbitrary shell command and buffers its output; fork specifically starts a new Node.js process running a given script and wires up an IPC channel for structured message passing between parent and child.
Is there a way to share a real in-memory cache across cluster workers?
Not directly - since cluster workers are separate processes, a shared cache has to live outside any single worker, typically in Redis or another external store that all workers can read and write.