Connection Pooling, Explained
Why backends reuse a fixed set of database connections instead of opening a new one per request.
3 min read
Opening a database connection isn't free — it involves a TCP handshake, authentication, and setup on the database server's side, often taking tens of milliseconds. A backend that opens a brand-new connection for every incoming request pays that cost on every single request, and can exhaust the database server's connection limit under real traffic. Connection pooling fixes both problems by reusing a small set of already-open connections.
How a pool works
At startup, the backend opens a fixed number of connections to the database and keeps them open — the pool. When a request needs to query the database, it borrows a connection from the pool, uses it, and returns it, rather than opening and closing a new one.
Pool (size 10): [conn1, conn2, conn3, ... conn10]
Request A needs a query → borrows conn3 → runs query → returns conn3 to pool
Request B needs a query → borrows conn1 → runs query → returns conn1 to pool
If all connections are currently borrowed when a new request needs one, that request waits (up to a configurable timeout) until a connection frees up, rather than opening an eleventh connection unchecked.
// pseudocode
pool = createPool({ min: 2, max: 10, database: DATABASE_URL })
async function getUser(id):
connection = await pool.acquire()
try:
return await connection.query("SELECT * FROM users WHERE id = $1", [id])
finally:
pool.release(connection)
Most database client libraries and ORMs provide pooling built in — you rarely write this logic yourself, but understanding what's happening underneath explains a lot of production behavior that's otherwise mysterious.
Sizing the pool
Bigger isn't automatically better. Every open connection consumes memory and resources on the database server itself, and most databases have a hard cap on total concurrent connections (PostgreSQL defaults to 100). A pool sized too large, especially multiplied across many backend server instances, can exhaust that cap — the classic symptom is too many connections errors under load, even though the actual query volume wasn't unreasonable.
A pool sized too small causes the opposite problem: requests queue up waiting for a connection even when the database itself isn't under heavy load, adding latency that looks like a database performance problem but is really a pool configuration problem.
Why this matters more with serverless and many instances
The math gets tricky once a backend scales horizontally: 20 server instances each with a pool of 10 means up to 200 concurrent database connections, which can blow past a database's connection limit even if each individual instance's pool looks reasonable in isolation. This is a common real-world cause of a database that mysteriously falls over during a traffic spike — it's not usually the actual query load, it's connection exhaustion. Serverless backends (where "instances" can spin up and down unpredictably) often need a connection-pooling proxy (like PgBouncer) sitting in front of the database for exactly this reason.
Pooling makes individual queries fast and reliable. The next lesson covers a different lever for performance — avoiding the database entirely for data you've already fetched, with caching.