Sizing Connection Pools for Node.js Cluster and PM2 Workers
This guide is part of Node.js Async Connection Limits. One Node.js process uses one CPU core, so production deployments run several: the cluster module, PM2 in cluster mode, or several containers per node. Each worker is a separate process with a separate pool, and the pool size that looked reasonable in a single-process development environment is multiplied by the worker count — often by the core count of the machine, which nobody chose deliberately:
# pm2 start app.js -i max on a 16-core node
PostgreSQL: FATAL: sorry, too many clients already
# 16 workers × pool max 10 = 160 connections from one container
The objective is a pool size derived from the per-worker share of the database budget, a worker count that is explicit rather than inherited from the hardware, and a reload strategy that does not double the connection count while old and new workers overlap.
Rapid incident diagnosis
Count the workers and multiply. The connection total is the product of three numbers that are usually set in three different places:
pm2 jlist | jq -r '.[] | "\(.name) instances=\(.pm2_env.instances) node_args=\(.pm2_env.node_args)"'
kubectl get deploy orders-api -o jsonpath='{.spec.replicas}'
grep -rn "max:" --include=*.js --include=*.ts src/db*
Total connections are replicas × workers_per_replica × pool_max. Compare that with the database’s usable budget before looking for anything more subtle.
Check whether the pool was created before the fork. A pool constructed in the master process and inherited by workers shares sockets between processes, which corrupts protocol state and produces errors that look random:
// Wrong: the pool exists before cluster.fork()
const pool = new pg.Pool();
if (cluster.isPrimary) { for (let i = 0; i < 4; i++) cluster.fork(); }
The symptoms are parse errors, results delivered to the wrong request, or Connection terminated unexpectedly under load.
Attribute sessions to workers. Setting application_name with the worker id makes the distribution visible:
const pool = new pg.Pool({
application_name: `orders-api-w${process.env.NODE_APP_INSTANCE ?? cluster.worker?.id ?? 0}`,
});
SELECT application_name, count(*) FROM pg_stat_activity
WHERE application_name LIKE 'orders-api-w%' GROUP BY application_name ORDER BY 1;
An uneven distribution is normal — the operating system balances accepted connections unevenly — but a worker with far more sessions than its pool maximum indicates a leak in that worker.
Watch for doubling during reloads. PM2’s reload starts a new worker before stopping the old one, so during a reload the connection count rises by one worker’s pool per worker replaced.
Mathematical sizing or parameter formula
Work backwards from the database’s budget for this service:
pool_max_per_worker = max(1, floor( service_budget ÷ (replicas × workers_per_replica) ))
With a budget of 120 connections, 6 replicas and 4 workers each: floor(120 ÷ 24) = 5.
Then check that 5 is enough for one worker’s concurrency:
in_flight_per_worker = requests_per_second_per_worker × queries_per_request × mean_query_seconds
At 150 requests per second per worker, 2 queries of 6 ms: 150 × 2 × 0.006 = 1.8 in flight, so a pool of 5 has ample headroom. When the budget-derived number is below the concurrency-derived number, the fleet is too large for the database and the options are fewer workers, fewer replicas, or a proxy.
Reload overlap. A rolling reload adds one worker’s pool per worker being replaced:
peak_during_reload = steady + (workers_reloading_at_once × pool_max × replicas_reloading)
PM2 reloads one worker at a time per instance by default, so with 6 replicas reloading simultaneously the overlap is 6 × 5 = 30 extra connections — which must fit inside the budget, or the reload itself causes the incident.
Worker count should be explicit. -i max binds the worker count to the host’s core count, which changes when the node pool changes:
workers_per_replica = min(cores_available_to_the_container, chosen_value)
In containers, the CPU limit — not the node’s core count — is the number that matters, and Node.js does not always see it. Set the count explicitly rather than relying on detection.
Exact remediation & configuration
Create the pool inside the worker, after the fork:
// server.js
import cluster from "node:cluster";
import os from "node:os";
const WORKERS = Number(process.env.WEB_CONCURRENCY ?? 4); // explicit, not os.cpus().length
if (cluster.isPrimary) {
for (let i = 0; i < WORKERS; i++) cluster.fork();
cluster.on("exit", (worker, code) => {
console.warn(`worker ${worker.process.pid} exited (${code}); starting a replacement`);
cluster.fork();
});
} else {
const { start } = await import("./app.js"); // the pool is constructed inside app.js
await start();
}
// db.js — imported only by workers
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.DB_POOL_MAX ?? 5),
min: 0,
idleTimeoutMillis: 120_000,
connectionTimeoutMillis: 2_000,
application_name: `orders-api-w${process.env.NODE_APP_INSTANCE ?? cluster.worker?.id ?? 0}`,
});
pool.on("error", (err) => console.error({ err }, "idle client error"));
With PM2, set the instance count and the pool size together:
// ecosystem.config.cjs
module.exports = {
apps: [{
name: "orders-api",
script: "server.js",
instances: 4, // explicit; not "max"
exec_mode: "cluster",
env: {
WEB_CONCURRENCY: "1", // PM2 already provides the processes
DB_POOL_MAX: "5", // budget ÷ (replicas × instances)
},
kill_timeout: 10_000, // give shutdown time to drain the pool
listen_timeout: 8_000,
}],
};
Note WEB_CONCURRENCY: 1: when PM2 runs in cluster mode it forks the processes itself, so the application must not fork again.
Drain the pool on shutdown, or a reload leaves connections until the server times them out:
async function shutdown(signal) {
server.close(); // stop accepting
await new Promise((r) => setTimeout(r, 1_000)); // let in-flight requests finish
await pool.end(); // close pool connections
process.exit(0);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
PM2 sends SIGINT before SIGKILL, and kill_timeout controls the gap; Kubernetes sends SIGTERM and uses terminationGracePeriodSeconds. Handling both keeps the same code correct in either environment.
Keep the three numbers in one place. A single configuration value — the service’s connection budget — with the pool size derived from it at startup prevents the three settings from drifting apart:
const BUDGET = Number(process.env.DB_BUDGET ?? 120);
const REPLICAS = Number(process.env.REPLICAS ?? 6);
const WORKERS = Number(process.env.WEB_CONCURRENCY ?? 4);
const max = Math.max(1, Math.floor(BUDGET / (REPLICAS * WORKERS)));
Reloads, autoscaling and the overlap window
The steady-state arithmetic is the easy part; most incidents happen during change.
Rolling reloads overlap. PM2’s reload and Kubernetes’ rolling update both run old and new processes together. The overlap is bounded by how many are replaced at once, and the budget must include it:
pm2 reload orders-api --update-env # one worker at a time per instance
With Kubernetes, maxSurge: 1 at the pod level plus PM2’s one-worker-at-a-time reload keeps the overlap to a single worker’s pool per replica.
Autoscaling changes the replica term. A horizontal autoscaler that can triple the replica count triples the connection total, and the pool size must be derived from the maximum replica count rather than the current one. The budget check belongs in the same place as the autoscaler’s maximum:
pool_max = floor( budget ÷ (max_replicas × workers) )
Worker crashes and restarts. A worker that crashes and is replaced briefly holds two pools’ worth of connections — the dead worker’s sockets remain until the operating system or the database notices. With idleTimeoutMillis set and the database’s own idle handling configured, that window is short; without them, a crash loop can accumulate connections until the database refuses them.
Zero-downtime deploys need the budget, not just the readiness probe. A deploy that passes its readiness checks can still exhaust the database if the overlap was not budgeted. The check is arithmetic, and it belongs in CI alongside the other budget checks described in Calculating a Connection Budget Across Microservices.
| Event | Extra connections | Control |
|---|---|---|
| PM2 reload | one worker’s pool per replica | pm2 reload, not restart |
| Kubernetes rolling update | maxSurge × workers × pool |
maxSurge: 1 |
| Autoscale to maximum | (max − current) × workers × pool |
derive pool from max_replicas |
| Worker crash and respawn | one worker’s pool, briefly | idleTimeoutMillis, server idle timeout |
Validation & verification
Connection total matches the arithmetic. At peak, the database’s session count for the service should equal replicas × workers × pool_max, within a few connections:
SELECT count(*) FROM pg_stat_activity WHERE application_name LIKE 'orders-api-w%';
No pool is inherited across a fork. Grep the startup path to confirm the pool module is imported only inside the worker branch, and verify at runtime that the primary process holds no database sessions of its own.
Reload stays inside the budget. Trigger a reload under load and sample the session count every second. The peak must stay below the budget; if it does not, reduce maxSurge or the pool size.
Shutdown drains. After stopping a worker, its sessions must disappear from pg_stat_activity within a second or two rather than lingering until an idle timeout — which proves pool.end() ran.
Frequently Asked Questions
Should each worker get an equal share of the budget?
Is -i max ever the right setting?
Does pool.min need to be zero?
idleTimeoutMillis can moderate.What about worker_threads instead of cluster?
Related
- Node.js Async Connection Limits — the parent guide to concurrency and pools in Node.js.
- Tuning node-postgres Pool Timeout Settings — the per-pool timeouts referenced above.
- Calculating a Connection Budget Across Microservices — where the service budget comes from.
- Implementing Graceful Connection Pool Shutdown in Express — draining a pool correctly on shutdown.