Why one Node.js process leaves cores idle
A Node.js process runs your JavaScript on one thread: the event loop. Every route handler, middleware function, JSON.parse call and template render executes there, one at a time. While a handler is computing, no other request on that process makes progress, even if the machine has idle cores.
Node does use more than one thread, but only for specific work. Network sockets are handled by the operating system's asynchronous I/O facilities, so waiting on a database or upstream API costs almost no CPU. libuv's thread pool, which defaults to four threads, takes file system operations, dns.lookup, compression, and some crypto functions such as pbkdf2 and scrypt. Native addons can also do work off the main thread. None of this runs your application logic in parallel. Serializing a large response, validating a big payload, or hashing a password synchronously all happen on the event loop.
The result is predictable. An I/O-heavy API on one process can handle a lot of traffic. Once request handling becomes CPU-bound, that process saturates one core while the rest stay idle. Latency then climbs because requests queue behind each other.
The fix is to run more processes. The question is which layer owns them:
- Process layer: the
clustermodule forks workers from your own code. - Host layer: PM2 supervises processes on a single machine.
- Fleet layer: Kubernetes schedules replicas across nodes.
These tools are not competing answers to the same question. This distinction matters most for backend and platform engineers running multi-core APIs in production.
How cluster, PM2 and Kubernetes replicas work
Node.js cluster
The cluster module forks a primary process into N workers. Each worker runs your server code and calls listen() on the same port, but the sockets are not independent. On Linux and macOS the primary owns the listening socket, accepts each connection and passes it to a worker round-robin. On Windows the default is different: workers accept from the shared handle and the OS decides who wins, which can distribute connections unevenly. Workers are full OS processes with their own heap, event loop and module cache. An in-memory cache or rate-limit counter in one worker is invisible to the others.
PM2 cluster mode
PM2 uses the same cluster mechanism and manages it for you. It restarts crashed workers, collects their logs, reloads workers one at a time for zero-downtime deploys, generates init-system startup scripts and keeps a process list you can inspect. The trade-off is an extra dependency and a long-running daemon that now sits between your init system and your application.
Kubernetes replicas
A Deployment runs N pods, usually one Node process each, behind a Service. The Service balances at the network layer across pods, which may sit on different nodes. The platform handles liveness and readiness probes, restarts, rolling updates and horizontal autoscaling through the HPA.
cluster: [primary] --round-robin--> worker1 | worker2 | worker3 (one host)
PM2: [pm2 daemon] -> [primary] -> worker1 | worker2 | worker3 (one host)
Kubernetes: [Service] --per connection--> pod A (node 1) | pod B (node 2)
The shared limitation
All three models balance by connection, not by work. Cluster's round-robin ignores how expensive each request is, so one worker can stall on a heavy request while others sit idle. A Service picks a backend when a connection opens. Keep-alive HTTP/1.1, HTTP/2 and gRPC clients send many requests over a few long-lived connections. Those connections stay pinned to whichever pods they reached first, so the load across pods stays uneven until clients reconnect.
(1) cluster -- one host
+------------------------------------------------------------+
| clients --> [primary: accepts conns, round-robin] |
| | | | |
| v v v |
| [worker 1] [worker 2] [worker 3] |
| own heap own heap own heap |
+------------------------------------------------------------+
(2) PM2 cluster mode -- one host
+------------------------------------------------------------+
| [pm2 daemon: restarts, logs, reloads, startup, list] |
| | manages |
| v |
| clients --> [primary] --> [worker 1] [worker 2] [worker 3] |
+------------------------------------------------------------+
(3) Kubernetes replicas -- many nodes
+------------------------------------------------------------+
| clients --> [Service: balances per connection] |
| | | |
| v v |
| +---------------+ +---------------+ |
| | node 1: pod A | | node 2: pod B | |
| +---------------+ +---------------+ |
| platform: probes, restarts, rolling updates, HPA |
+------------------------------------------------------------+
All three balance per connection, not per request cost:
a keep-alive, HTTP/2 or gRPC connection stays on one target.
Cluster and PM2 spread connections across worker processes with separate heaps on one host. PM2 adds a managing daemon, while a Kubernetes Service spreads connections across pods on multiple nodes, and none of the three balances by per-request cost.
Implementing each model: minimal cluster, PM2 and Deployment examples
Cluster module
The primary process reads a worker count from an environment variable such as WEB_CONCURRENCY, forks that many workers, and re-forks any worker that exits unexpectedly. Each worker runs the same HTTP server. The primary holds no request logic, so a crash in application code takes down one worker, not the whole service.
PM2
The ecosystem file declares the app with exec_mode: "cluster" and an explicit instances value. On a VM, PM2 itself needs a supervisor. Run it under a systemd unit so the PM2 daemon restarts after a reboot or crash. PM2 restarts the workers, and systemd restarts PM2.
Kubernetes Deployment
Each container runs one Node process. The replicas field sets the process count. CPU and memory requests let the scheduler place pods, and limits bound them. A readiness probe removes a pod from Service endpoints while it starts up or drains. A liveness probe restarts a process that has hung. The two probes should hit different endpoints so that a slow dependency does not trigger restarts.
Why every example sets the count explicitly
Inside a container, os.cpus() often returns the host's cores rather than the container's CPU quota. If you size workers from it, a pod limited to 2 CPUs on a 16-core node can fork 16 workers. Under the CFS quota, those workers share 200 ms of CPU time per 100 ms period. Illustrative estimate: if 16 workers are all busy, they use up that 200 ms in about 12.5 ms of wall time. The kernel then throttles the whole container for the rest of the period, and tail latency jumps.
// server.js: the primary forks a fixed number of workers, and each worker serves HTTP.
const cluster = require('node:cluster');
const http = require('node:http');
// Read the size from config, not os.cpus(). In a container, os.cpus() may report host cores.
const parsed = Number.parseInt(process.env.WEB_CONCURRENCY ?? '2', 10);
const workers = Number.isInteger(parsed) && parsed > 0 ? parsed : 2;
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
if (cluster.isPrimary) {
// The primary holds no request logic. It only starts and replaces workers.
for (let i = 0; i < workers; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
// Re-fork only on unexpected exits, not during an intentional shutdown.
if (!worker.exitedAfterDisconnect) {
console.error(`worker ${worker.process.pid} exited (${signal ?? code}), re-forking`);
cluster.fork();
}
});
process.on('SIGTERM', () => {
// Graceful drain: each worker stops accepting new connections
// and exits after its in-flight requests finish.
for (const id in cluster.workers) cluster.workers[id].disconnect();
});
} else {
// Every worker runs the same server. Incoming connections are distributed across workers.
http
.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ pid: process.pid }));
})
.listen(port);
}
A minimal cluster primary/worker script that reads its worker count from WEB_CONCURRENCY instead of os.cpus(), re-forks workers that crash, and drains workers on SIGTERM.
Graceful shutdown and state across processes
Shutdown: drain, then exit
All three models stop a process with a signal, followed by a hard kill if the process takes too long. Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds (30 by default) before sending SIGKILL. PM2 sends SIGINT by default and escalates after its kill_timeout. Your handler should do the same work in each case:
- Stop accepting connections with
server.close(). - Let in-flight requests finish.
- Close database pools.
- Exit.
Set a timer that forces exit before the hard kill arrives. The SIGTERM example below follows this sequence.
Reloads replace processes one at a time. pm2 reload starts a new worker before it stops the old one. With raw cluster you write this loop yourself: fork, wait for listening, then disconnect the old worker. In a Kubernetes rolling update, a new pod receives traffic only after its readiness probe passes. Removing the old pod from endpoints happens at the same time as SIGTERM, not before it, so a short preStop sleep keeps that pod from rejecting requests that are still being routed to it.
In containers, PID 1 gets no default signal handlers from the kernel. A process without a SIGTERM handler ignores the signal and is SIGKILLed when the grace period ends. Running npm start as PID 1 can also fail to forward signals to the Node process. Run node directly with the exec form of CMD, or use an init such as tini.
State stops being shared
When there is more than one process, these break:
- in-memory sessions
- caches
- rate-limit counters
- WebSocket affinity
Move the first three to an external store such as Redis, and use sticky routing for sockets. Connection pools also multiply. Illustrative estimate: 4 pods × 8 workers × a pool of 10 is 320 database connections.
const http = require('node:http');
const { Pool } = require('pg');
// One pool per process: total DB connections = processes x max.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const server = http.createServer(async (req, res) => {
try {
const { rows } = await pool.query('SELECT now() AS now');
res.end(JSON.stringify(rows[0]));
} catch (err) {
res.statusCode = 500;
res.end('error');
}
});
server.listen(3000);
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return; // ignore repeated signals
shuttingDown = true;
console.log(`${signal} received, draining`);
// Exit on our own before the hard kill (Kubernetes default grace is 30s).
setTimeout(() => process.exit(1), 25_000).unref();
// Stop accepting connections; the callback runs once in-flight requests finish.
server.close(async (err) => {
try { await pool.end(); } finally { process.exit(err ? 1 : 0); }
});
// Idle keep-alive sockets would otherwise hold close() open (Node 18.2+).
server.closeIdleConnections();
}
process.on('SIGTERM', () => shutdown('SIGTERM')); // Kubernetes, cluster workers
process.on('SIGINT', () => shutdown('SIGINT')); // PM2's default stop signal
A Node.js HTTP server handles SIGTERM and SIGINT by refusing new connections, letting in-flight requests finish, closing its pg pool, and forcing an exit before the orchestrator's hard kill.
When to avoid nesting PM2 or cluster inside a pod
The kubelet supervises containers, not the processes inside them. When a cluster primary or PM2 forks eight workers and one dies, the container keeps running, the liveness probe usually still passes, and the pod reports healthy. Your restart counts, events and alerts never see the failure. One process per container fixes this: if Node exits, the kubelet restarts it, records the restart and applies backoff.
Nesting also muddies resource accounting. A pod with a 2-CPU limit running eight workers gets throttled under the CFS quota, and the throttling shows up as latency rather than as a clear capacity signal. The Horizontal Pod Autoscaler sees one pod's averaged CPU. It cannot tell one hot worker from evenly spread load, so scaling decisions lag or overshoot.
PM2 adds its own risks. Its restart loop is quick and quiet, so a crash-looping bug can run for days and show up only as intermittent 502s. Memory-based restart thresholds have a similar problem. They recycle a worker when its heap grows, which removes the symptom while the leak keeps running and remains undiagnosed.
Deliberate exceptions
Running several workers per pod can be worth it on very large nodes, where thousands of single-core pods would strain the scheduler and networking. It can also help when sidecars and service mesh proxies cost enough per pod that one Node process per pod would waste a large share of each pod's resources. If you take that route, you accept less precise health signaling. You also become responsible for supervising workers yourself, including exposing per-worker liveness and exiting the whole container when workers fail repeatedly.
Choosing between cluster vs PM2 vs Kubernetes: a decision guide
The deciding question is whether something outside the process already supervises it.
| Dimension | cluster under systemd |
PM2 | Kubernetes replicas |
|---|---|---|---|
| Failure domain | One host | One host | Node, zone, or region, depending on scheduling |
| Scaling ceiling | Cores on the host | Cores on the host | Cluster capacity |
| Health signaling | Whatever you write | Process liveness and memory limits | Liveness and readiness probes |
| Restarts | Your primary forks workers, and systemd restarts the primary | Automatic, per worker | kubelet restarts the container |
| Zero-downtime deploys | You implement rolling worker swaps | Built-in reload in cluster mode | Rolling updates gated on readiness |
| Autoscaling | None | Manual instance count | Horizontal Pod Autoscaler |
| Operational overhead | Low tooling, more custom code | Low | High unless a platform team already runs it |
| Best fit | Minimal-dependency hosts | Single VM or bare metal | Existing orchestrated platforms |
On a single VM or bare-metal box, use PM2. If you want fewer dependencies and are willing to own the supervision code, use cluster under systemd. Either way, the host is a single point of failure, and throughput stops growing once every core is busy.
If you already run Kubernetes or another orchestrator, run one process per pod and scale with replicas. The scheduler's probes and restart behavior then see each process directly.
Adopting Kubernetes just to use more cores is rarely justified for a small API. PM2 on a single host gets you the same core usage with far less operational surface.
worker_threads solves a different problem. Use it to move CPU-heavy work inside a request, such as image resizing or parsing, off the event loop. A thread pool shares one process and its failure fate, so it complements process-level scaling rather than replacing it.
Impact on cost and engineering time
The largest engineering-time saving is not writing supervision code. A hand-rolled cluster primary must restart crashed workers, back off on crash loops, forward signals and coordinate reloads. PM2 provides that behavior, so the team maintains configuration instead of code. On an orchestrator, one process per pod goes further. The kubelet already restarts containers, so dropping the in-pod supervisor removes duplicated logic and leaves one log stream, one process to profile, and a probe that reports the real worker's health. Kubernetes itself carries substantial operational work, including upgrades, networking and resource tuning. That cost is justified only if you already run it or need it for other services.
Sizing pulls the other way. Every pod pays fixed costs such as runtime baseline memory, any sidecars, request headroom and startup time. As a result, many small pods can use more total memory than a few multi-worker pods. Autoscaling has its own cost. An I/O-bound API can saturate on connections or downstream latency while CPU stays low, so CPU-based scaling reacts late. Request-based or custom metrics fix this but require a metrics pipeline and adapter.
Illustrative estimate (assumed numbers, not a benchmark):
- Pods = ceil(target RPS ÷ (workers per pod × RPS per worker))
- Memory = pods × (workers per pod × per-process MB + per-pod overhead MB)
Assume a target of 2,400 RPS, 300 RPS per worker, 80 MB per process and 50 MB of per-pod overhead:
- One worker per pod: 8 pods × 130 MB = 1,040 MB
- Four workers per pod: 2 pods × 370 MB = 740 MB
Real per-worker throughput depends on the workload, I/O wait and garbage-collection behavior. Load test before trusting any of these inputs.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to node.js cluster vs pm2, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.