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_maxfor 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.
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.
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.
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?
pool_max can be safely lowered, not how much budget a pool may claim.How do serverless functions fit into the formula?
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?
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?
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?
Related
- Connection Pool Sizing Formulas — the parent guide on sizing a single pool.
- Sizing Connection Pools for Kubernetes Horizontal Pod Autoscaling — per-service replica arithmetic that feeds this budget.
- Applying Little’s Law to Connection Pool Sizing — deriving each service’s
pool_maxfrom traffic. - Tuning PostgreSQL max_connections and superuser_reserved_connections — choosing the server ceiling the budget divides.