π Connection Pooling
Opening a database connection is expensive β a TCP handshake, authentication, and memory allocation every single time. A connection pool keeps a handful of connections warm and lends them out, so requests skip that cost. This lesson explains how pooling works, how to size and configure it, and how to keep it healthy under load.
Week 8 · Day 2 (Tuesday: PostgreSQL with Node.js) · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why creating a new connection per request cripples performance
- Describe the checkout β use β return lifecycle of a pooled connection
- Configure the
pgPool:max, timeouts,maxUses, and event handlers - Reason about the right pool size for your database and app instances
- Monitor pool health with
totalCount,idleCount, andwaitingCount - Know when the built-in pool isn't enough and PgBouncer earns its keep
Estimated Time: 70 minutes
Project: Add pool configuration, graceful shutdown, and a /health/db metrics endpoint to your app.
In This Lesson
Why Pooling?
Every time a client connects to PostgreSQL, a lot happens under the hood: a TCP handshake, TLS negotiation (in production), password authentication, and the server forking a backend process with its own memory. That setup can take tens of milliseconds β often longer than the query itself. Do it on every HTTP request and your database spends more time greeting visitors than serving them.
Picture a busy restaurant. Without reservations, every arriving guest waits while a table is dragged out, set, and dressed β then torn down the moment they leave. A connection pool is a row of tables kept set and ready: guests are seated instantly, and when they leave, the table is wiped and reused for the next party. Same tables, far less overhead.
π The payoff
- Speed: connection setup happens once, not per request
- Resource control: the pool caps how many connections you ever open, protecting the database
- Scalability: steady response times as concurrency climbs
- Safety: reusing a bounded set avoids exhausting PostgreSQL's
max_connections
The Checkout / Return Model
A pool owns a fixed maximum number of physical connections. When your code needs one, it checks out an idle connection; while checked out, that connection is exclusively yours. When you're done, you return it to the pool, wiped and ready for the next request. If every connection is busy, new requests wait in a queue until one frees up.
max: 2, requests A and B each hold a connection; request C waits in the queue until one is returned. Configure max to match what your database can safely handle.Two things make this reliable: connections are returned promptly (so the queue keeps moving), and max is set so the pool never demands more connections than PostgreSQL will allow. Break either and you get the failure modes we cover below.
Configuring the Pool
The pg Pool constructor takes your connection details plus pool-tuning options. Here's a production-shaped configuration with event handlers and graceful shutdown.
// db.js
require('dotenv').config();
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // most connections the pool will open
idleTimeoutMillis: 30000, // close a connection after 30s idle
connectionTimeoutMillis: 2000, // fail fast if none free within 2s
maxUses: 7500, // recycle a connection after 7500 checkouts
// ssl: { rejectUnauthorized: false }, // required by most managed hosts
});
// The pool emits events you can log or alert on
pool.on('connect', () => console.log('New physical connection opened'));
pool.on('error', (err) => console.error('Idle client error:', err.message));
// Graceful shutdown: drain the pool before the process exits
process.on('SIGINT', async () => {
await pool.end();
console.log('Pool drained, exiting');
process.exit(0);
});
module.exports = {
query: (text, params) => pool.query(text, params),
getClient: () => pool.connect(), // for transactions
pool, // exported for metrics / advanced use
};
What each option does
| Option | Meaning | Default | Typical |
|---|---|---|---|
max | Maximum connections in the pool | 10 | 10β20 per instance |
idleTimeoutMillis | How long a connection may sit idle before being closed | 10000 | 30000 |
connectionTimeoutMillis | How long a checkout waits before giving up | 0 (forever) | 2000β5000 |
maxUses | Checkouts before a connection is retired and replaced | Infinity | 7500 |
allowExitOnIdle | Let Node exit when the pool is idle | false | true for CLI scripts |
β οΈ Set connectionTimeoutMillis
The default is "wait forever." Under a traffic spike that means requests pile up silently instead of failing fast. A 2β5 second timeout lets you detect overload and respond with a clean 503 rather than hanging every client.
Sizing the Pool
Pool size is a Goldilocks problem. Too small and requests queue behind a handful of connections; too large and you overwhelm PostgreSQL β each backend consumes memory, and blowing past max_connections makes the whole server refuse work. Bigger is not better.
The key insight most beginners miss: every Node instance has its own pool. Four app servers each with max: 25 can demand 100 connections. Your budget is the database's max_connections (minus a reserve for admin tools), divided across all instances.
π‘ Worked example
Postgres max_connections = 200. Reserve 30 for admin β 170 available. Deployed across 5 instances β 170 / 5 = 34 per instance. If a single instance rarely runs more than ~15 queries at once, a max of around 20 is plenty β well under the 34 ceiling, with headroom to spare. Start conservative and raise it only if monitoring shows a persistent queue.
A widely cited starting heuristic for CPU-bound query workloads is connections = (cpu_cores * 2) + 1 β but treat it as a floor to measure from, not gospel. Real sizing comes from watching waitingCount under real load.
Usage Patterns
Pattern 1 β Simple query (let the pool manage it)
For a one-shot query with no transaction, use pool.query(). It checks out a connection, runs the query, and returns it for you β the common case.
const db = require('./db');
async function getUserById(id) {
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0];
}
Pattern 2 β Dedicated client for a transaction
When statements must share one connection (a transaction), check out a client and β critically β release it in finally.
const db = require('./db');
async function transferFunds(fromId, toId, amount) {
const client = await db.getClient();
try {
await client.query('BEGIN');
const debit = await client.query(
`UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1 RETURNING *`,
[amount, fromId]
);
if (debit.rowCount === 0) throw new Error('Insufficient funds');
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId]
);
await client.query('COMMIT');
return { success: true };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release(); // return it to the pool no matter what
}
}
Pattern 3 β Watching the queue
The pool exposes live counters you can read at any moment to understand its state.
const { pool } = require('./db');
function poolStatus() {
return {
total: pool.totalCount, // connections currently open
idle: pool.idleCount, // open but not checked out
waiting: pool.waitingCount, // requests queued for a connection
};
}
β οΈ Never nest checkouts
Don't call getClient() again from inside code that already holds a client, especially while looping. Under load that can deadlock: each held connection waits for another that will never free up. Do batch work on the one client you already have.
Monitoring & Troubleshooting
The three pool counters tell you almost everything. Expose them on a health endpoint so your monitoring system (or just curl) can watch them.
// A lightweight metrics endpoint
const { pool } = require('./db');
app.get('/health/db', async (req, res) => {
try {
await pool.query('SELECT 1'); // prove the database answers
res.json({
ok: true,
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
});
} catch (err) {
res.status(503).json({ ok: false, error: err.message });
}
});
Reading the signals
| Symptom | Likely cause | Fix |
|---|---|---|
waitingCount stays high, idleCount ~0 | Pool too small, or connections not released | Audit for a missing release(); then consider raising max |
totalCount pinned at max forever | Connection leak (checked out, never returned) | Move every release() into a finally; set maxUses |
timeout exceeded when trying to connect | Pool exhausted, all connections busy | Shorten slow queries, add caching, raise max if the DB allows |
| DB rejects new connections | Total across all instances > max_connections | Lower per-instance max, or add PgBouncer |
π‘ A leak looks like a size problem β but isn't
The number-one cause of a "we need a bigger pool" ticket is a forgotten client.release() on some code path (often an early return or an error branch). Fix the leak first; a bigger pool just delays the same wall.
Beyond Node: PgBouncer
The built-in pool is per-process. Once you run many Node instances β or serverless functions that each spin up their own pool β the total connection count can overwhelm PostgreSQL even though each pool looks modest. PgBouncer is a tiny external pooler that sits between your apps and the database and manages one shared set of real connections.
Your Node pools connect to PgBouncer instead of directly to Postgres, and PgBouncer multiplexes many client connections onto a small pool of backend connections. In transaction pooling mode β the norm for web apps β a real backend is held only for the duration of each transaction, so a handful of Postgres connections can serve thousands of clients.
| Pooling mode | A backend is held for⦠| Best for |
|---|---|---|
| Session | The whole client connection | Session state, LISTEN/NOTIFY, prepared statements |
| Transaction | One transaction | Most web apps & REST APIs |
| Statement | One statement | Many tiny autocommit queries |
β οΈ Transaction pooling has trade-offs
In transaction mode, session-scoped features stop working reliably: server-side prepared statements, LISTEN/NOTIFY, and SET variables all assume one client keeps one backend. Many drivers need prepared statements disabled against PgBouncer. Reach for it when connection count is a real bottleneck β not by default on day one.
Practice & Quiz
ποΈ Exercise 1: A health endpoint
Goal: Add GET /health/db that runs SELECT 1 and returns the pool's totalCount, idleCount, and waitingCount β or a 503 if the query fails.
π‘ Hint
Export the pool from db.js. Read the counters directly off the pool object; wrap the probe query in try/catch.
β Solution
const { pool } = require('./db');
app.get('/health/db', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({
ok: true,
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
});
} catch (err) {
res.status(503).json({ ok: false, error: err.message });
}
});
ποΈ Exercise 2: Size a pool
Goal: Postgres has max_connections = 100. You reserve 20 for admin tools and deploy 4 identical Node instances. What is the largest safe max per instance, and why shouldn't you just set it to that?
π‘ Hint
Available = 100 β 20. Divide by the number of instances. Then think about whether one instance actually needs that many concurrent connections.
β Solution
Available connections: 100 β 20 = 80. Divided by 4 instances: 80 / 4 = 20 is the ceiling per instance. Setting max: 20 is safe, but you should only use what you need β if an instance rarely exceeds ~10 concurrent queries, max: 10β12 leaves headroom for a fifth instance later and reduces idle backends. Size from measured waitingCount, staying under the 20 ceiling.
π― Quick Quiz
Question 1: The main reason a connection pool speeds up a web app is that it:
Question 2: Your pool's totalCount is stuck at max and idleCount is 0, yet traffic is light. The most likely cause is:
Question 3: Why must you account for the number of app instances when sizing max?
Best Practices & Pitfalls
β Do
- Use one shared
Poolper process via a singledb.jsmodule - Set
connectionTimeoutMillisso overload fails fast instead of hanging - Release every checked-out client in a
finallyblock - Size
maxfrom the DB budget Γ· instances, then trim to measured demand - Drain the pool with
pool.end()on graceful shutdown - Monitor
waitingCountβ a persistent queue is your signal to act
β Don't
- Create a new
Poolper request (that defeats the entire point) - Leave
maxso high that all instances together exceedmax_connections - Nest
getClient()calls or check out inside a loop - Assume "add more connections" fixes a leak β find the missing release first
- Enable server-side prepared statements against PgBouncer transaction pooling
β One pool, imported everywhere
Instantiate the pool exactly once and require that module across your app. Multiple pools in one process silently multiply your connection count and undo your careful sizing.
Summary
π Key Takeaways
- Connection setup is expensive; a pool reuses warm connections to skip that cost per request
- The model is checkout β use β return, with extra requests queued until a connection frees
- Tune the
Poolwithmax,idleTimeoutMillis,connectionTimeoutMillis, andmaxUses - Size
maxagainst the database'smax_connectionsdivided by all app instances - Watch
totalCount,idleCount, andwaitingCount; a stuck-full pool usually means a leak, not a size problem - PgBouncer centralizes pooling across many instances when connection count becomes the bottleneck
π Additional Resources
- node-postgres β Pool API
- node-postgres β Pooling guide
- PostgreSQL β Connection settings (max_connections)
π What's Next?
You've now covered relational databases end to end β design, setup, querying, and pooling. Next we change paradigms entirely: MongoDB Concepts introduces document-oriented NoSQL, where data lives as flexible JSON-like documents instead of rows and columns, and you'll see when each model is the right tool.
π Pool mastered!
You understand why pooling matters, how to size and configure it, and how to spot trouble before it takes your app down. That's production-grade database plumbing.