RDS refused connections at 10% CPU: default connection pools times replicas
Load tests hit a flat plateau of ~420 database connections and the request was for a much bigger database. The real ceiling was max_connections versus Npgsql default pools.
During load tests on a staging environment at Gambchamp, .NET services started failing to open database connections. The request that reached me was simple: “we need a much bigger database”. CPU on the RDS instance had spiked to 97% in the same windows, so the request looked reasonable.
The shape of the graph said otherwise.
A plateau, not a curve
CloudWatch DatabaseConnections showed a flat line at 412–424 for tens of minutes. The seven-day average was about 7. Capacity problems usually bend a curve; a flat top is a limit being hit.
The instance was a db.t4g.medium with the default max_connections formula, which works out to 399. Subtract superuser_reserved_connections=3 and reserved_connections=2, and ordinary roles get 394 slots.
Where 400 connections came from
None of the connection strings set Max Pool Size, so Npgsql used its default: 100 connections per process, per connection string. There was no PgBouncer and no RDS Proxy; every pod connected directly.
The PostgreSQL log in the peak window showed three client IPs on one database — the three replicas of an integration service — and one more on another database. Three times 100 plus 100 is roughly 400: the application ceiling, almost to the connection.
The proof was a specific FATAL line, 18 times in one test and 47 in the next:
remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role
Two readings that mislead
- CloudWatch peaked above
max_connections(424 > 399). The metric also counts backends that were forked and then rejected with FATAL, so it overshoots during a storm. too many clients alreadynever appeared. That message means even the reserved slots are gone. The “remaining connection slots are reserved” message fires earlier, when only the ordinary slots run out — which is exactly what happened here.
Logs Insights limited to the peak window was the only practical way to see this: a plain filter over a log group with infinite retention simply never finished.
A bigger instance moves the wall
A teammate resized the instance to db.m7g.large, which raises the limit to 832 (827 for applications). That buys headroom, but it does not remove the wall: 17 pods mount database connection strings, so the theoretical demand is at least 17 × 100 = 1,700.
The real fix is on the application side: an explicit Max Pool Size per service so that the sum of replicas × pool size stays at or below about 80% of the application slots. I handed that to the service owners with the numbers. If replica counts become dynamic with HPA, the next step is RDS Proxy or PgBouncer. I also added a node dashboard so the next load test shows node saturation too — 19 of 20 deployments in that namespace had no resource limits.
Takeaways
- A flat plateau in connections is a limit, not a capacity curve. Check the FATAL text before resizing anything.
- Npgsql pools are per process and per connection string: a pod talking to three databases can hold 300 connections.
- Budget connections explicitly: Σ(replicas × pool size) ≤ ~80% of
max_connectionsminus reserved slots. - CloudWatch
DatabaseConnectionscan exceedmax_connectionsduring a rejection storm. That is not a bug in the limit.