Hermes Wiki
Developer/Languages/TypeScript/AsyncConcurrencyPatterns/Fundamentals/worker-threads-vs-child-process-vs-cluster-in-node

Worker Threads vs. Child Process vs. Cluster: Node's Three Parallelism Models

Concept

Node runs JavaScript on a single thread with an event loop — async/await and Promises give the appearance of concurrency for I/O-bound work (a DB query, an HTTP call) by letting the loop service other callbacks while waiting on I/O, but they do not create parallelism: a synchronous CPU-bound function still blocks that one thread for its entire duration, and nothing else runs until it returns. Node's standard library exposes three genuinely different ways to get real parallelism or process-level scale-out, and they solve different problems:

  • worker_threads spins up additional V8 isolates within the same Node process, each with its own event loop, that can run genuine JavaScript in parallel on separate OS threads. Crucially, they support SharedArrayBuffer for actual shared memory between threads (with Atomics for safe concurrent access), and creating a worker is comparatively cheap next to a full process. This is the tool for CPU-bound work — image resizing, hashing/encryption, heavy computation — that would otherwise block the main event loop.
  • child_process (spawn, exec, fork) launches a genuinely separate OS process, with its own memory space and no shared memory — communication happens over IPC (pipes, serialized messages), which is slower than passing data to a worker thread. Process creation itself is comparatively expensive. This is the right tool for running an external program (ffmpeg, a Python script, a shell command) — anything that isn't Node/JavaScript code at all — or for isolating a task so completely that a crash in it can't take down the parent.
  • cluster doesn't parallelize a single task — it forks multiple copies of the entire Node application (each a full process) and load-balances incoming connections across them via a shared listening socket. This scales a web server's request-handling capacity across CPU cores, which matters because a single Node process, even with a non-blocking event loop, only ever uses one core for its JS execution.

Tradeoffs

Model What it parallelizes Memory model Best for Cost
worker_threads One CPU-bound task, off the main thread Shared memory available (SharedArrayBuffer/Atomics), lower overhead than a process CPU-intensive computation (encryption, image/video processing, large data transforms) that would otherwise block the event loop More complex application structure; thread coordination, message-passing, and error handling need real care, and debugging across threads is harder than single-threaded code
child_process An entirely separate program or script No shared memory — IPC only (pipes, serialized messages) Running external tools/binaries, or isolating a task so its crash can't affect the parent process Heaviest overhead of the three — process creation is expensive, and IPC serialization is slower than in-process communication
cluster The whole application, replicated across cores Fully separate processes (no shared memory, no shared in-memory state across workers) Scaling a web server's connection-handling capacity across CPU cores In-memory state (a cache, a WebSocket connection registry) isn't shared across cluster workers by default — needs an external store (Redis, etc.) if workers must coordinate

The three aren't competing solutions to one problem — they answer three different questions: "how do I stop this CPU-bound function from blocking everything else" (worker_threads), "how do I run something that isn't JavaScript" (child_process), and "how do I use more than one core to serve more concurrent connections" (cluster). A real production Node service commonly uses more than one of the three for different parts of the system.

When to use / when not to

  • Reach for worker_threads specifically when a CPU-bound computation inside the Node process itself is long enough to visibly stall the event loop (noticeable request-latency spikes under load) — offload that one function, not the whole app.
  • Reach for child_process when the actual work is an external program, or when a task's isolation (crash containment, a different security boundary, a different language/runtime) matters more than communication speed.
  • Reach for cluster (or an equivalent process manager like PM2's cluster mode) when the workload is I/O-bound web traffic and the bottleneck is that a single process can only use one core — this is a horizontal-scaling tool for connection throughput, not a fix for a slow CPU-bound handler.
  • Don't reach for worker_threads to fix I/O-bound slowness (a slow DB query, a slow downstream API) — async/await already handles that without threads; adding threads there adds coordination complexity for no throughput gain, since the bottleneck isn't CPU time on the main thread.
  • Don't assume cluster gives workers shared in-memory state — an in-process cache or a rate-limiter counter kept in a plain JS object works fine in a single process but silently becomes per-worker (and therefore inconsistent) the moment cluster forks multiple copies of the app; that state needs to move to a shared external store first.

Common pitfall

Reaching for worker_threads or a bigger cluster size to fix what's actually an I/O-bound bottleneck. Because async/await already makes I/O-bound work non-blocking on a single thread, most Node performance problems that look like "we need more parallelism" are actually a slow downstream call, an unindexed query, or a missing cache — none of which more threads or more processes fixes, since the CPU sits mostly idle waiting on I/O either way. The tell is profiling: a CPU-bound bottleneck shows the event loop itself pegged doing computation (worker threads help); an I/O-bound bottleneck shows the process mostly idle, waiting on network/disk (worker threads and extra cluster workers don't help — the fix is reducing or parallelizing the I/O calls themselves, e.g. batching requests or fixing a slow query).

Engineering Lens

The single-threaded-with-an-event-loop design is Node's central bet: most server workloads are I/O-bound, so a model optimized for cheap, non-blocking I/O beats a thread-per-request model for that class of workload, at the cost of needing an explicit escape hatch (worker_threads) for the CPU-bound minority. The failure mode worth watching for in a design review isn't "did we pick the wrong one of these three" — it's a team defaulting to throwing more parallelism primitives at a symptom without first establishing, via profiling, whether the bottleneck is actually CPU-bound at all. A cluster deployment scaled to 16 workers to fix latency that turns out to be a missing database index doesn't fix the latency — it just runs the same slow query 16 times in parallel, at 16x the infrastructure cost.

Sources

Hermes Wiki