Sizing Connection Pools for Kubernetes Horizontal Pod Autoscaling

This guide is part of Connection Pool Sizing Formulas. Autoscaling breaks pool sizing in a specific and predictable way: the number that divides the connection budget is the replica count, and under an HPA that number is not a constant. A pool sized against today’s four replicas is correct until the autoscaler reaches sixteen — which happens under exactly the load that made the scale-out necessary.

The failure has a characteristic shape. Everything works for months. Traffic rises, the HPA adds pods, and the newly created pods fail to start serving while the existing ones continue normally:

2026-07-26T14:22:11Z FATAL: sorry, too many clients already
2026-07-26T14:22:11Z  at Connection.parseE (/app/node_modules/pg/lib/connection.js:614:13)
# readiness probe fails, pod never becomes ready, HPA sees latency rise and scales further

The autoscaler then responds to the resulting latency by adding more pods, each of which fails the same way. The scale-out is the incident.

Rapid Incident Diagnosis

Four checks, in order, confirm the diagnosis and give the inputs for the fix.

  1. Does the failure correlate with pod count rather than traffic? Plot the database’s total backend count against the deployment’s replica count. If they move together and the errors begin at a specific replica number, the divisor is wrong.
  2. What is the HPA’s ceiling? kubectl get hpa <name> -o jsonpath='{.spec.maxReplicas}'. This, not the current count, is the number that matters.
  3. What surge does the deployment allow? kubectl get deploy <name> -o jsonpath='{.spec.strategy.rollingUpdate.maxSurge}'. A percentage applies to the desired count, so at maxReplicas it adds that fraction again.
  4. How many pools per pod? Worker processes, sidecars, and any second data source each hold their own.

The product of those numbers, multiplied by the configured pool size, is the demand at maximum scale. Compare it to the service’s share of max_connections.

Demand steps with each replica the autoscaler adds Every replica the horizontal pod autoscaler adds contributes its full pool, so backend demand rises in steps and crosses the service's allowance at a specific replica count. 0 backends service allowance — 180 replica 10 — allowance reached pods here cannot connect 2 6 14 replicas, as the HPA scales out Sizing against the current replica count sets the crossing point at whatever scale happened to be in place that day.
Each replica contributes its full pool, so demand is a step function of replica count and the allowance is crossed at a predictable, computable point.

The Divisor Formula

processes_at_max = ceil(maxReplicas × (1 + maxSurge_fraction)) × pools_per_pod

per_process_pool = floor(service_allowance ÷ processes_at_max)

Each term needs care.

maxReplicas comes from the HPA, not from kubectl get pods. If several HPAs target the same workload — a KEDA scaler alongside a CPU-based HPA, for instance — the effective ceiling is the higher of them.

maxSurge applies during every rollout and stacks on top of the current count. Expressed as a percentage it is a fraction of the desired replicas, so at maxReplicas it adds that fraction again. A maxSurge: 25% with maxReplicas: 16 means 20 pods can exist simultaneously.

pools_per_pod covers worker processes inside the pod — Gunicorn, Puma, the Node cluster module — and any additional data source. A pod running four Gunicorn workers against a writer and a replica holds eight pools.

# deployment.yaml — the numbers that determine the divisor
spec:
  strategy:
    rollingUpdate:
      maxSurge: 25%          # ← multiplies the ceiling during every rollout
      maxUnavailable: 0
---
# hpa.yaml
spec:
  minReplicas: 3
  maxReplicas: 16            # ← the divisor's base, not the current count

With a service allowance of 180, maxReplicas 16, maxSurge 25% and 2 workers per pod: ceil(16 × 1.25) = 20 pods, × 2 = 40 processes, 180 ÷ 40 = 4 per process.

Exact Remediation and Configuration

Three changes make the sizing durable rather than a one-time correction.

Set the pool from the derived value, with the derivation recorded. The comment is not decoration — it is what stops the next maxReplicas change from silently invalidating the number.

env:
  # 180 allowance ÷ (16 maxReplicas × 1.25 maxSurge × 2 workers) = 4
  # RAISING maxReplicas REQUIRES RECOMPUTING THIS.
  - name: DB_POOL_MAX
    value: "4"
  - name: DB_POOL_MIN
    value: "1"
  - name: DB_ACQUIRE_TIMEOUT_MS
    value: "2500"

Set maxSurge: 0 where the budget is tight. It trades rollout speed for a lower peak, and on a service that sits close to its allowance it removes the surge term from the divisor entirely. The cost is that a rollout briefly reduces capacity rather than briefly increasing it.

Add a PodDisruptionBudget and readiness gating. A pod that cannot obtain a connection should fail readiness rather than accept traffic, so the load balancer keeps routing to healthy pods while the situation resolves. Combined with a minAvailable budget this prevents a partial connection failure from becoming a full outage.

The structural alternative, for a service whose maximum scale genuinely needs more connections than its allowance permits, is a multiplexing proxy — which removes the replica count from the arithmetic entirely. That decision is covered in Choosing a Connection Proxy for Serverless Postgres, whose reasoning applies equally to an aggressively autoscaled deployment.

Three ways to bound autoscaled demand Dividing by the surge maximum bounds demand at the cost of a small per-process pool. Setting maxSurge to zero removes one multiplier. A proxy removes the replica count from the arithmetic entirely. divide by the surge maximum the arithmetic fix ✓ no infrastructure change ✗ small pool per process start here — it is free maxSurge: 0 removes one multiplier ✓ 20–25% more per process ✗ rollouts reduce capacity briefly worth it when the budget is tight multiplexing proxy removes replica count entirely ✓ scale freely ✗ session-state audit, new component the durable answer above a certain scale The first two are configuration changes deployable this afternoon; the third is a project. Do the first two regardless.
Two of the three are configuration-only and should be applied immediately. The third is the durable answer for a deployment whose maximum scale genuinely exceeds its allowance.

Validation and Verification

The test that matters is a deliberate scale-out to the HPA’s maximum, run before traffic forces one.

# Force the deployment to its ceiling and watch the backend count.
kubectl scale deploy/orders-api --replicas=16
watch -n2 'kubectl get pods -l app=orders-api --no-headers | grep -c Running'

# From a psql session against the database, in parallel:
SELECT count(*) AS backends, max_conn.setting::int AS limit
FROM pg_stat_activity,
     LATERAL (SELECT setting FROM pg_settings WHERE name = 'max_connections') max_conn
WHERE application_name = 'orders-api'
GROUP BY max_conn.setting;

Two conditions must hold. Every pod reaches Running and passes readiness — no pod fails to obtain a connection. And the backend count settles at or below the derived allowance, with the whole database still under 80% of max_connections.

Then repeat with a rollout in progress, which is the case the surge term exists for:

kubectl rollout restart deploy/orders-api    # at maxReplicas, with maxSurge active

If the backend count exceeds the allowance only during the rollout, the surge term was omitted from the divisor.

Scale-out test, before and after Before the correction, backend count crosses the allowance partway through the scale-out. After it, the count rises to the allowance and stops, with every pod reaching ready. 0 allowance before — crosses at replica 10 after — settles at the allowance scale-out to maxReplicas → Run this deliberately, on a quiet afternoon, rather than discovering the crossing point during a traffic spike.
A deliberate scale-out to the HPA ceiling is a ten-minute test that answers definitively whether the divisor is right, and it is far cheaper than the alternative.

Frequently Asked Questions

Should the pool scale with the pod count automatically?
There is no built-in mechanism, and building one is usually the wrong direction — it makes the connection count a moving target that no capacity plan can bound. Fixing the per-process value from the maximum is simpler and safer; if the resulting value is too small, that is a signal to reduce process count or add a proxy.
What about a VerticalPodAutoscaler alongside the HPA?
VPA changes resource requests rather than replica count, so it does not affect the divisor directly. It can affect it indirectly if worker count is derived from CPU allocation — a Gunicorn configuration using 2 × cores + 1 will change worker count when VPA changes the CPU request, silently altering the multiplier.
Do init containers and jobs count toward the budget?
Yes, for as long as they run. A migration job that opens a connection during every rollout adds to the peak at exactly the moment the surge is also active. Give such jobs their own small allowance rather than assuming they are free.
How does KEDA change this?
Not structurally — it is another autoscaler with its own maximum. What it does change is how quickly the maximum is reached: KEDA scaling on queue depth can go from minimum to maximum far faster than a CPU-based HPA, which makes an incorrect divisor produce a failure much sooner after the traffic change.
Is it safe to raise maxReplicas without touching the pool?
Only if the resulting demand still fits the allowance. That is exactly the check the recorded derivation exists to prompt — raising maxReplicas from 16 to 32 doubles the demand, and if the pool value is unchanged, the crossing point simply moves closer.
Where should the derived value live — the manifest or the application config?
In the manifest, as an environment variable, next to the maxReplicas it was derived from. Keeping the two in the same file means a change to one is visible when reviewing the other, which is the only reliable mechanism for keeping them consistent over time.
Should the HPA scale on a database-aware metric?
It is tempting and usually a mistake. Scaling on acquisition wait time creates a feedback loop: pool pressure adds replicas, which add connections, which increase pool pressure. Scale on request-side signals — CPU, request rate, queue depth — and let the connection budget be a fixed constraint the sizing respects.
What about a StatefulSet rather than a Deployment?
The arithmetic is the same but the surge behaviour differs: StatefulSets replace pods one at a time by default, so there is no surge term unless it is configured. That makes the divisor simpler and is a small argument in favour of StatefulSets for connection-constrained workloads.
Does the readiness probe need its own connection?
It needs one only if it queries the database, and a probe that does adds a borrow per probe interval per replica. On a large fleet with a two-second probe interval that is a meaningful share of the pool — cache the result for a few seconds, or check a cheaper signal.