Skip to main content

🏊 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 pg Pool: max, timeouts, maxUses, and event handlers
  • Reason about the right pool size for your database and app instances
  • Monitor pool health with totalCount, idleCount, and waitingCount
  • 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.

Requests check out idle connections from the pool, use them against Postgres, and return them; extra requests wait in a queue Requests req A req B req C (waiting) Connection Pool (max: 2) conn 1 β€” in use conn 2 β€” in use queue: [ req C ] checkout ⇄ return Postgres backends
With 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

OptionMeaningDefaultTypical
maxMaximum connections in the pool1010–20 per instance
idleTimeoutMillisHow long a connection may sit idle before being closed1000030000
connectionTimeoutMillisHow long a checkout waits before giving up0 (forever)2000–5000
maxUsesCheckouts before a connection is retired and replacedInfinity7500
allowExitOnIdleLet Node exit when the pool is idlefalsetrue 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.

graph TD A["PostgreSQL max_connections"] --> B["Reserve for admin/maintenance"] B --> C["Available for the app"] C --> D["Divide by number of instances"] D --> E["max per instance"] F["Peak concurrent queries per instance"] --> E E --> G["Choose the smaller, add a small buffer"]

πŸ’‘ 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

SymptomLikely causeFix
waitingCount stays high, idleCount ~0Pool too small, or connections not releasedAudit for a missing release(); then consider raising max
totalCount pinned at max foreverConnection leak (checked out, never returned)Move every release() into a finally; set maxUses
timeout exceeded when trying to connectPool exhausted, all connections busyShorten slow queries, add caching, raise max if the DB allows
DB rejects new connectionsTotal across all instances > max_connectionsLower 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.

graph TD A[Node instance 1] --> P[PgBouncer] B[Node instance 2] --> P C[Node instance 3] --> P P --> D[(PostgreSQL)]

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 modeA backend is held for…Best for
SessionThe whole client connectionSession state, LISTEN/NOTIFY, prepared statements
TransactionOne transactionMost web apps & REST APIs
StatementOne statementMany 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 Pool per process via a single db.js module
  • Set connectionTimeoutMillis so overload fails fast instead of hanging
  • Release every checked-out client in a finally block
  • Size max from 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 Pool per request (that defeats the entire point)
  • Leave max so high that all instances together exceed max_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 Pool with max, idleTimeoutMillis, connectionTimeoutMillis, and maxUses
  • Size max against the database's max_connections divided by all app instances
  • Watch totalCount, idleCount, and waitingCount; 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

πŸš€ 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.