Calculating a Connection Budget Across Microservices

This guide is part of Connection Pool Sizing Formulas. Sizing one pool is a queueing problem. Sizing a dozen pools that share one PostgreSQL primary is an accounting problem, and the incidents it causes rarely coincide with high traffic. They happen during a deploy, when a rolling update briefly runs old and new pods side by side, or during a scale-out that each team considered separately:

2026-09-11 14:02:51 UTC [48117] FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
2026-09-11 14:02:51 UTC [48118] FATAL:  sorry, too many clients already

Every service’s pool was individually reasonable. Their sum at maximum replica count, plus the surge pods of one deploy, was not. The objective here is a written budget: a formula that turns each service’s pool settings and scaling limits into a worst-case connection count, and an allocation that keeps that worst case below the server’s usable slots.

Rapid incident diagnosis

When the error appears, find out who holds the slots and whether the total is a transient surge or a steady state.

SELECT application_name,
       usename,
       count(*)                                   AS connections,
       count(*) FILTER (WHERE state = 'active')   AS active,
       count(*) FILTER (WHERE state = 'idle')     AS idle
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name, usename
ORDER BY connections DESC;

If services do not set application_name, group by client_addr and map addresses to pods with kubectl get pods -o wide. Then check what the orchestrator was doing at the time:

kubectl get events -A --field-selector reason=ScalingReplicaSet \
  --sort-by=.lastTimestamp | tail -20
kubectl get hpa -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CUR:.status.currentReplicas,MAX:.spec.maxReplicas

Three patterns account for almost every budget incident:

  • Deploy surge. The connection count jumps by roughly one service’s maxSurge × pool_max for a few minutes, then falls back. Old pods have not yet closed their pools while new pods have opened theirs.
  • Autoscaling ceiling. One service’s replica count reached its HPA maximum, and its pool size times that maximum was never compared with the budget.
  • Idle floor. Most connections are idle. Pools configured with a large minimum idle count hold slots nobody is using, so the budget is consumed by reservations rather than by load.

The third pattern is the cheapest to fix and the one to look for first: an idle share above 60 % means pool minimums, not traffic, are the problem.

Connection count during a rolling deployment A timeline shows connections at 360 before a deploy, rising to 400 while surge pods hold new pools and old pods still hold theirs, crossing the usable budget of 390, then settling back to 360 when old pods terminate. Before budgeting: connections to the primary during a payments-api rollout usable budget 390 400: surge pods and old pods overlap steady state 360 back to 360 rollout starts old pods terminated 14:00 14:08
A rolling update briefly runs old and new pods together. With a surge of 25 percent, one service's connection claim rises by a quarter for as long as the rollout takes.

Mathematical sizing or parameter formula

Start with the slots the database actually gives to applications. On PostgreSQL:

usable = max_connections − superuser_reserved_connections − reserved_connections − ops_reserve

ops_reserve covers what is not a service pool: migration runners, cron jobs, the monitoring agent, a logical replication consumer and at least two operator sessions. Background workers and WAL senders have their own slots in current PostgreSQL releases and do not come out of max_connections.

Each service’s worst-case claim is its pool maximum times the most processes it can ever run, including deploy surge:

claim_i = pool_max_i × processes_per_pod_i × ( max_replicas_i + surge_i )
surge_i = ceil( max_replicas_i × maxSurge_i )

The budget holds if the sum of claims fits, allowing for the fact that only one service is normally deploying at a time:

Σ pool_max_i × processes_i × max_replicas_i   +   max_i( pool_max_i × processes_i × surge_i )   ≤   usable

Worked example with max_connections = 500, three superuser slots, reserved_connections = 5 and an operations reserve of 22:

usable = 500 − 3 − 5 − 22 = 470
Service Pool max Processes per pod Max replicas Steady claim Surge (25 %)
orders-api 10 1 12 120 30
payments-api 8 1 10 80 24
catalog-api 5 4 (gunicorn workers) 6 120 40
notifications-worker 4 1 8 32 8
reporting 20 1 2 40 20
Total 392 largest: 40

The worst case is 392 + 40 = 432, inside 470 with a margin of 38. Note the catalog-api row: a pool of 5 looks small, but four worker processes per pod make it the largest claim after surge. Per-process multipliers are the most common thing left out of a budget. If the margin had been negative, the order of remedies is: reduce pool_max where measured peak concurrency is well below it, cap max_replicas where the HPA ceiling was never tied to the database, and only then consider a proxy or a larger instance.

Stacked connection claims against the usable budget A horizontal stacked bar of steady claims totalling 392, plus the largest deploy surge of 40, fits inside the usable budget of 470 out of max_connections 500. max_connections 500 → usable 470 for services orders 120 payments 80 catalog 120 notif. 32 reporting 40 surge 40 usable edge 470 Worst case = 392 steady + 40 surge = 432 margin 38 connections, about 8 % of usable slots Order of remedies if the margin is negative 1. shrink pool_max to measured peak 2. cap HPA max_replicas 3. add a proxy
Claims are stacked at their maximum replica counts, and the largest single surge is added on top. The small-looking catalog pool becomes a large claim once worker processes are counted.

Exact remediation & configuration

Make the budget executable rather than a spreadsheet that drifts. Three changes do that.

Name every connection. Without application_name, the budget cannot be audited. Set it in every service’s connection string:

# orders-api, Spring Boot
spring:
  datasource:
    url: jdbc:postgresql://orders-db:5432/orders?ApplicationName=orders-api
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2          # idle floor kept small: slots are shared
      idle-timeout: 120000
# catalog-api, Django settings.py — 4 gunicorn workers per pod
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "catalog",
        "OPTIONS": {
            "application_name": "catalog-api",
            "pool": {"min_size": 1, "max_size": 5, "timeout": 5},
        },
    }
}

Enforce each claim at the database. A per-role connection limit turns an unplanned scale-out into an error confined to one service, rather than an outage for all of them. Set it to the service’s claim including its own surge:

ALTER ROLE orders_app        CONNECTION LIMIT 150;  -- 120 + 30
ALTER ROLE payments_app      CONNECTION LIMIT 104;
ALTER ROLE catalog_app       CONNECTION LIMIT 160;
ALTER ROLE notifications_app CONNECTION LIMIT 40;
ALTER ROLE reporting_app     CONNECTION LIMIT 60;

The role limits add up to more than the usable budget, deliberately: each role may use its surge allowance, and the formula already assumes only one service surges at a time.

Tie deploy strategy to the budget. Lower maxSurge for the services with the largest claims, or use maxUnavailable so the rollout replaces pods rather than adding them:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-api
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # one extra pod: 4 processes × 5 = 20 connections, not 40
      maxUnavailable: 1

Add a CI check that recomputes the formula from the manifests on every pull request, reading maximum-pool-size, worker counts and HPA maximums, and failing the build when the worst case exceeds usable. The budget then changes only when someone changes it on purpose. When the fleet outgrows any sensible allocation, the next step is a multiplexing proxy, compared in PgBouncer vs RDS Proxy vs pgpool-II.

Three layers that enforce a connection budget Pool maximum per process in the application configuration, connection limit per role in PostgreSQL, and maxSurge in the deployment strategy each cap a different multiplier in the budget formula. Pool settings maximum-pool-size pool max_size caps each process Role limit ALTER ROLE ... CONNECTION LIMIT caps each service Deploy strategy maxSurge, maxUnavailable HPA maxReplicas caps process count Each layer caps a different term of the budget formula a CI check recomputes the worst case from manifests on every change and fails the build when it exceeds the usable slots
The budget is enforced at three layers. Pool settings bound each process, role limits bound each service, and the deploy strategy bounds how many processes exist at once.

Validation & verification

Verify the formula against reality, then against the event that broke it.

Compare each role’s live count with its claim. The ratio should stay below 1.0 during normal traffic and approach, but not reach, it during deploys:

SELECT r.rolname,
       r.rolconnlimit                        AS claim,
       count(a.pid)                          AS live,
       round(count(a.pid)::numeric / NULLIF(r.rolconnlimit, 0), 2) AS ratio
FROM pg_roles r
LEFT JOIN pg_stat_activity a ON a.usename = r.rolname
WHERE r.rolconnlimit > 0
GROUP BY r.rolname, r.rolconnlimit
ORDER BY ratio DESC NULLS LAST;

Check server headroom as a single number worth graphing and alerting on:

SELECT current_setting('max_connections')::int
     - current_setting('superuser_reserved_connections')::int
     - count(*) AS free_slots
FROM pg_stat_activity
WHERE backend_type = 'client backend';

Then rehearse the worst case in staging: scale the largest service to its HPA maximum and trigger a rollout of it at the same time, while sampling free_slots every ten seconds. The minimum observed must stay above the operations reserve. Finally, confirm the role limit works by scaling one service beyond its maximum; its new pods should log too many connections for role "catalog_app" while every other service stays healthy.

Automating the budget check in CI

A budget written in a wiki is correct on the day it is written. The version that stays correct is a check that runs whenever a manifest changes. The inputs are all in the deployment repository: pool maximums in application configuration, worker process counts in container commands, replica ceilings in HPA manifests, and surge settings in deployment strategies.

A small script collects them into a table and evaluates the formula. The version below reads a YAML file that each service maintains next to its manifests, which keeps parsing simple and makes the budget inputs reviewable in pull requests:

# budget/orders-db.yaml
database: orders-db
max_connections: 500
superuser_reserved_connections: 3
reserved_connections: 5
ops_reserve: 22
services:
  - { name: orders-api,           pool_max: 10, processes: 1, max_replicas: 12, max_surge: 0.25 }
  - { name: payments-api,         pool_max: 8,  processes: 1, max_replicas: 10, max_surge: 0.25 }
  - { name: catalog-api,          pool_max: 5,  processes: 4, max_replicas: 6,  max_surge: 1 }
  - { name: notifications-worker, pool_max: 4,  processes: 1, max_replicas: 8,  max_surge: 0.25 }
  - { name: reporting,            pool_max: 20, processes: 1, max_replicas: 2,  max_surge: 1 }
#!/usr/bin/env python3
import math, sys, yaml

cfg = yaml.safe_load(open(sys.argv[1]))
usable = (cfg["max_connections"] - cfg["superuser_reserved_connections"]
          - cfg["reserved_connections"] - cfg["ops_reserve"])

steady, surges = 0, []
for s in cfg["services"]:
    per_pod = s["pool_max"] * s["processes"]
    steady += per_pod * s["max_replicas"]
    surge_pods = s["max_surge"] if s["max_surge"] >= 1 else math.ceil(s["max_replicas"] * s["max_surge"])
    surges.append((per_pod * surge_pods, s["name"]))

worst_surge, surge_service = max(surges)
worst = steady + worst_surge
print(f"usable={usable} steady={steady} worst_surge={worst_surge} ({surge_service}) worst={worst}")
if worst > usable:
    sys.exit(f"connection budget exceeded by {worst - usable}")

Run it in CI on any change under budget/, and add a second check that compares each service’s YAML entry with its actual manifests — for example, that max_replicas equals the HPA’s spec.maxReplicas — so the budget file cannot drift from what is deployed.

Budgets also need an owner. The platform or database team owns max_connections and the reserves; each service team owns its row. A pull request that raises a service’s claim beyond the remaining margin should require review from the database owner, which turns an eventual outage into a conversation about whether to add a proxy, split the database, or trim another service’s allocation.

Change Budget impact Who reviews
Raise pool_max Δpool × processes × (max_replicas + surge) Service team; database owner if margin < 10 %
Raise HPA maxReplicas Δreplicas × pool_max × processes Service team; database owner if margin < 10 %
Add worker processes per pod Multiplies the whole row Database owner
Change max_connections Changes usable for everyone Database owner

Frequently Asked Questions

Should the budget use pool maximum or measured peak usage?
Use the pool maximum. A pool can open up to its maximum during a latency spike, which is exactly when the database is least able to absorb extra sessions. Measured peaks tell you where pool_max can be safely lowered, not how much budget a pool may claim.
How do serverless functions fit into the formula?
They do not fit well, because their process count is bounded by concurrency limits rather than replica counts, and each instance holds its own pool. Treat reserved concurrency as max_replicas with a pool of 1, or put a proxy in front of them. See Serverless Function Connection Management.
Do read replicas need a separate budget?
Yes. Each replica has its own max_connections, and a replica’s value must be at least as large as the primary’s for hot standby to start. Budget read-only pools against the replica they target, not against the primary.
Is reserved_connections worth configuring?
On PostgreSQL 16 and later it creates a pool of slots usable only by roles granted pg_use_reserved_connections. Grant it to the monitoring and operator roles so that a full budget never blinds your observability during the incident it causes.
What margin should the budget keep?
Ten to fifteen percent of usable slots is a practical minimum. It absorbs an unplanned debugging session, a stuck deploy that leaves surge pods running, or a job someone forgot to list, without anyone being refused a connection.