Scaling Databases & Data Consistency · Scaling Reads

Connection Poolers at Scale

Many services, many pods, serverless functions — and one database's connection budget.

Scenario

Example company: 14 microservices on Kubernetes plus a fleet of serverless functions all talk to one Postgres primary (max_connections = 500). Each pod opens a pool of 20. During a traffic spike the autoscaler adds pods.

What you see in production

At 3× normal traffic:

FATAL:  remaining connection slots are reserved for non-replication superuser connections
FATAL:  sorry, too many clients already

Database memory climbs with thousands of mostly idle backend processes, and latency rises before CPU is saturated.

The naive code

Every process opens its own pool sized for its own peak:

# 14 services × up to 30 pods × pool 20  =  8,400 potential connections
env:
  - name: DB_POOL_SIZE
    value: "20"

Reproduce it

Compute the connection demand with direct pools vs a transaction-mode pooler that multiplexes clients onto a small server pool:

Connection budget: direct pools vs a poolerTry it yourself
services = {"api": 30, "checkout": 20, "search": 10, "workers": 15, "admin": 4}   # pods at peak
POOL_PER_POD = 20
MAX_CONNECTIONS = 500
direct = sum(pods * POOL_PER_POD for pods in services.values())
print(f"direct pools: {direct} connections needed vs max_connections={MAX_CONNECTIONS} "
      f"-> {'FAILS' if direct > MAX_CONNECTIONS else 'ok'}")

active_ratio = 0.05        # share of time a client is actually inside a transaction
server_pool = 120          # PgBouncer default_pool_size × pools
needed = direct * active_ratio
print(f"pooler: {direct} client connections share {server_pool} server connections; "
      f"~{needed:.0f} busy at once -> {'ok' if needed <= server_pool else 'queueing'}")

Output

direct pools: 1580 connections needed vs max_connections=500 -> FAILS
pooler: 1580 client connections share 120 server connections; ~79 busy at once -> ok

Root cause

Each Postgres connection is a process with its own memory; thousands of them waste RAM and add contention even when idle. Per-pod pools multiply with replicas and autoscaling, so total demand grows with deployment size rather than with real database work. Serverless platforms make it worse: every concurrent invocation can be a new connection.

The fix

  • Put a pooler between apps and the database: PgBouncer (transaction mode), RDS Proxy / Cloud SQL connectors, ProxySQL for MySQL.
  • Give each service a connection budget and keep pod pools small (5–10), relying on the pooler.
  • Reuse clients across serverless invocations (module scope) and point them at the proxy.
[databases]
app = host=10.0.0.5 dbname=app
[pgbouncer]
pool_mode = transaction
max_client_conn = 10000      ; cheap: client connections are just sockets
default_pool_size = 40       ; real server connections per (db, user)
reserve_pool_size = 10
server_idle_timeout = 60

Trade-offs

  • Transaction pooling breaks session state (see Connection Pools and Transactions) — audit for SET, session advisory locks, LISTEN, temp tables and prepared statements.
  • The pooler is a new hop and a new single point of failure; run it redundantly (several instances or sidecars).
  • Poolers fix connection count, not slow queries — a queue at the pooler still means the database is the bottleneck.

Detect it in production

  • SELECT count(*), state FROM pg_stat_activity GROUP BY state; versus max_connections.
  • PgBouncer SHOW POOLS; — cl_waiting > 0 for long means server connections are exhausted.
  • Track connections per service; alert when any service exceeds its budget.

Key points

  • Per-pod pools multiply with scale; the database pays per connection.
  • A transaction-mode pooler multiplexes thousands of clients onto a small server pool.
  • Budget connections per service; reuse clients in serverless functions.

Practice questions

Why does adding pods during a traffic spike cause 'too many clients' errors?

Answer

Each pod opens its own pool, so total connections = pods × pool size. Autoscaling multiplies connections past max_connections even though the database isn't doing proportionally more work.

How does PgBouncer in transaction mode reduce connection count?

Answer

Clients connect to PgBouncer cheaply; a server connection is assigned only for the duration of each transaction and then returned, so many clients share a small server pool.

How should serverless functions connect to a relational database?

Answer

Reuse one client per container across invocations, limit it to one or few connections, and connect through a proxy/pooler (RDS Proxy, PgBouncer) so concurrent invocations don't each open new server connections.

Open in the app to mark lessons complete, take notes and track your progress.