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.

How Node.js worker counts multiply the pool Container replicas multiply by workers per replica multiply by the pool maximum to produce the connection total the database sees. Replicas 6 Workers each 4 Pool max 10 = 240 connections Three settings, one total pm2 -i max or cluster.fork() per core makes the middle number depend on the hardware which is why the same image behaves differently on a bigger node
The connection total is a product of three numbers set in three places. Only the pool maximum is in the application's code.

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.

Pool maximum per worker for a fixed database budget With a budget of 120 connections, 12 total workers allow 10 each, 24 allow 5, 60 allow 2 and 120 allow 1, beyond which the fleet cannot be served without a proxy. Budget 120 connections ÷ total workers 12 workers: 10 24 workers: 5 60 workers: 2 120 workers: 1 below one connection per worker, add PgBouncer instead of shrinking further
With a fixed budget, the pool maximum per worker falls as the worker count rises. Past a point it reaches one, and the fleet needs a proxy rather than a smaller pool.

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
Connections during a PM2 reload During a reload the new worker's pool is created before the old worker's is closed, so the connection total rises by one worker's pool per replica for the duration of the overlap. Connections during a rolling reload peak: steady + one worker's pool per replica overlap window reload starts reload complete the budget must contain the peak, not the steady state
The overlap during a reload is a real claim on the budget. Sizing from the steady state alone means every deploy is a small connection spike.

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?
Usually yes, because the operating system spreads accepted connections roughly evenly and any worker may receive a burst. Unequal shares only make sense when workers have distinct roles — for example, one worker dedicated to background jobs.
Is -i max ever the right setting?
On a dedicated machine where the service owns the database budget, it is convenient. In containers with CPU limits, or with a shared database, it makes the connection total depend on the scheduler’s placement decisions, which is rarely what anyone intends.
Does pool.min need to be zero?
A minimum above zero holds connections permanently per worker, multiplied by the worker count. With many workers, zero or one is the right choice; the cost is connection establishment on the first request after an idle period, which idleTimeoutMillis can moderate.
What about worker_threads instead of cluster?
Worker threads share the process, so a pool created in the main thread can be used only by that thread — each worker thread that queries needs its own pool, and the same multiplication applies. Most web services use processes rather than threads for this reason.