# Database Connection Pooling: Why Your App Slows Down Under Load
Your app runs fine in development. It runs fine in staging. Then you get a traffic spike and everything grinds to a halt. Requests time out. Database queries take 10 seconds instead of 10 milliseconds. The CPU on your database server is barely doing anything.
Nine times out of ten, the culprit is connection pool exhaustion.
What Is a Database Connection?
When your app talks to PostgreSQL, it opens a TCP connection to the database server. Establishing that connection takes time — typically 5–50ms. That's fine if you do it once per request, less fine if every request opens a fresh connection.
More importantly, PostgreSQL has a limit on how many simultaneous connections it accepts. The default is 100. When you hit that limit, new connection attempts block and eventually time out.
The Problem with "One Connection Per Request"
In a Node.js app serving 50 concurrent requests, each hitting the database once, that's 50 simultaneous connections. Add some slow queries and you're at 80. A little more traffic and you hit the limit. New requests queue up waiting for a connection. The queue grows. Requests time out. Users see errors.
Connection Pooling
A connection pool is a set of pre-established database connections that your app reuses. Instead of opening and closing a connection per request, you borrow one from the pool, use it, and return it.
With a pool of 10 connections serving 100 concurrent requests:
- Requests that need the DB pick up an available connection immediately
- If all 10 are in use, they wait a short time (usually < 100ms) for one to free up
- You're using far fewer database connections for the same throughput
Setting Up Pooling in Node.js (pg)
const { Pool } = require('pg')
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // maximum pool size
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 2000, // fail fast if no connection available
})
// Use pool.query() everywhere instead of client.query()
async function getUser(id) {
const { rows } = await pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
)
return rows[0]
}That's it. One pool per process, shared across all requests.
Setting Up Pooling in Python (SQLAlchemy)
from sqlalchemy import create_engine
engine = create_engine(
os.environ['DATABASE_URL'],
pool_size=10,
max_overflow=5, # allow 5 extra connections when pool is full
pool_timeout=2, # wait max 2s for a connection
pool_recycle=1800 # recycle connections after 30min
)What Pool Size Should You Use?
A common mistake is setting pool size too high. More connections isn't always better — each PostgreSQL connection uses ~5–10MB of RAM on the database server.
A reasonable rule: pool_size = (2 × CPU cores on DB server) + effective_spindle_count
For a managed PostgreSQL instance with 2 vCPUs, start with max: 10. Watch your connection count under load and adjust.
On PandaStack, your managed database metrics show current connections. If you're consistently at 80%+ of your max, either increase the pool limit, scale the database, or add a dedicated pooler.
PgBouncer for High-Scale Apps
If you're running many app instances (horizontal scaling), each has its own pool. Five instances with a pool of 10 = 50 connections. This adds up.
PgBouncer is a lightweight connection pooler that sits between your app and PostgreSQL. All app instances connect to PgBouncer, which manages a smaller set of actual PostgreSQL connections.
For most apps under 10k requests/day, this isn't necessary. When it is, it's a panda deploy away.
The Signs You Have a Pool Problem
- Response times spike under load but CPU on both app and DB servers is low
- Database logs show
connection limit exceeded - Errors like
Connection timeoutortoo many connections - App works fine with 10 users, breaks with 100
Fix: check your pool configuration first. It's almost always set too small (or missing entirely).
Full docs: [docs.pandastack.io](https://docs.pandastack.io).