Connection Pooling
The Problem: A New Connection for Every Request
A database connection is an open line between your app and the database. Opening one is expensive:
1Open a connection2 → TCP handshake (set up the network link)3 → TLS handshake (set up encryption)4 → login and authentication5 → PostgreSQL starts a new process just for this connection6≈ 20–50 ms, plus several MB of memory on the DB server7 8Run the actual query ≈ 1–5 msIf our food delivery app opens a new connection for every request, most of the time is spent connecting, not querying. At dinner rush, thousands of requests open thousands of connections, and PostgreSQL refuses new ones after its limit (max_connections, 100 by default).
What a Connection Pool Is
A connection pool keeps a fixed set of connections open and reuses them. A request borrows a connection, runs its query, and returns it to the pool for the next request.
Think of shared bicycles at a station. Nobody builds a new bicycle for each ride. You take one, ride, and park it back for the next person.
1 ┌──────────── Pool (max 10) ────────────┐2Request 1 ──→ │ [conn 1: busy] [conn 2: busy] ... │ ──→ Database3Request 2 ──→ │ [conn 3: idle] [conn 4: idle] ... │4Request 3 ──→ └───────────────────────────────────────┘5 borrow → run query → returnThe 20–50 ms connection cost is paid once, when the pool starts, not on every request.
How Many Connections Do You Need?
Fewer than you'd think. A connection is busy only while a query runs:
1Requests per second: 2,0002Time each query holds a connection: 5 ms = 0.005 s3 4Connections busy at once = 2,000 × 0.005 = 10So 10 connections can serve 2,000 queries per second, if queries are fast.
More connections isn't faster. The database has a limited number of CPU cores. Hundreds of connections all running at once just fight for the same cores and memory. A common starting point for PostgreSQL is about (CPU cores × 2) + number of disks, then adjust by measuring.
Watch out when you scale app servers:
110 app instances × pool of 20 = 200 connections2PostgreSQL max_connections = 1003→ new connections failWhen many app instances or serverless functions share one database, put a connection pooler like PgBouncer in between. Thousands of app connections share a small number of real database connections.
1App instances (many) → PgBouncer → PostgreSQL (few real connections)Setting Up a Pool
const { Pool } = require('pg');const pool = new Pool({host: 'db-primary',max: 10, // max connections in the pool (pg default is 10)idleTimeoutMillis: 30000, // close connections idle for 30 sconnectionTimeoutMillis: 2000 // fail if no connection is free within 2 s});// pool.query borrows a connection and returns it automaticallyconst result = await pool.query('SELECT * FROM orders WHERE user_id = $1', [42]);
In Spring Boot, the same settings go in application.yml:
1spring:2 datasource:3 url: jdbc:postgresql://db-primary:5432/food4 hikari:5 maximum-pool-size: 106 minimum-idle: 57 connection-timeout: 2000When the Pool Runs Out
If every connection is busy, new requests wait. If they wait longer than the timeout, they fail.
1Pool max = 10210 slow queries each take 3 seconds3→ request 11 waits4→ after 2 s (connectionTimeout) → error: "timeout acquiring a connection"5→ users see errors even though the database itself is fineCommon causes:
| Cause | Fix |
|---|---|
| Slow queries hold connections for long | Add indexes, fix the queries |
| Connection leak: code borrows a connection and never returns it | Always release in finally |
| Long transactions or slow external calls inside a transaction | Keep transactions short; call payment APIs outside them |
| Pool too small for real traffic | Measure, then increase carefully |
A leak is the most common bug. With pool.query(), the pg library returns the connection for you. With pool.connect() (needed for transactions), you must return it yourself:
// LEAK: if a query throws, release() never runsconst client = await pool.connect();await client.query('UPDATE wallets SET balance = balance - 300 WHERE user_id = 42');client.release();// SAFE: release() always runsconst client2 = await pool.connect();try {await client2.query('UPDATE wallets SET balance = balance - 300 WHERE user_id = 42');} finally {client2.release();}
Ten leaks, and a pool of 10 is permanently empty. The app hangs until it restarts.
Interview Angle
How to Explain it in an Interview
Common mistakes:
- Thinking a bigger pool always means more throughput.
- Forgetting that pool size is per app instance.
- Running a transaction with
pool.query()calls in pg, which can use a different connection for each query.
Summary
1Connection → expensive to open (~20–50 ms + memory on DB)2Pool → keep a few open, borrow → query → return3Size → busy connections ≈ requests/sec × query time; small is fine4Total → app instances × pool size must stay under max_connections5Many instances → PgBouncer in front of PostgreSQL6Exhausted → requests wait, then time out7Causes → slow queries, leaks, long transactions8Rule → always release in finally / try-with-resourcesA pool keeps connections cheap, but a page can still send far too many queries through them. Next, the N+1 query problem shows how one page load can turn into 101 queries.