The problem
Without pooling, every request opens a new connection to the database, runs a query, and closes it. The TCP handshake alone takes 1-3ms. Add TLS negotiation and authentication, and you are spending 10-20ms per request just establishing a connection.
At 500 requests per second, that is 500 simultaneous connections. PostgreSQL's default max_connections is 100. You hit the wall fast.
How connection pools work
A pool maintains a set of open, reusable connections. When your code needs a connection, it borrows one from the pool. When the query finishes, the connection goes back to the pool — not closed, just returned.
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
min: 5, // keep 5 idle connections warm
max: 20, // never open more than 20
idleTimeoutMillis: 30000,
});
// Borrow, query, return — automatically
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
Sizing the pool
The optimal pool size is not "as many as possible." PostgreSQL performance degrades with too many concurrent connections because of lock contention and context switching.
A good starting formula:
pool_size = (core_count * 2) + effective_spindle_count
For a 4-core database server with SSDs, that is roughly 10 connections. With 5 app instances, set each pool to max: 2. This keeps the total under the database's comfort zone.
The serverless trap
Lambda functions and serverless deployments break pooling because each invocation might create its own pool. 1,000 concurrent Lambda invocations means 1,000 pools, potentially 20,000 connections.
The fix is an external connection pooler like PgBouncer:
Client → PgBouncer (port 6432) → PostgreSQL (port 5432)
PgBouncer holds a small number of real database connections and multiplexes your app's requests through them. Transaction-mode pooling is the right choice for most Node.js apps:
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
Detecting connection leaks
A leak happens when code borrows a connection and never returns it — usually because an error path skips the release:
// Leaky: if the query throws, client is never released
const client = await pool.connect();
const result = await client.query('SELECT ...');
client.release();
// Safe: finally always runs
const client = await pool.connect();
try {
const result = await client.query('SELECT ...');
return result.rows;
} finally {
client.release();
}
Better yet, use pool.query() directly for single queries — it handles checkout and release automatically.
Monitoring pool health
Expose pool metrics in your health endpoint:
app.get('/health', (req, res) => {
res.json({
pool: {
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
},
});
});
If waitingCount is consistently above zero, your pool is too small or your queries are too slow.
