How a Connection Pool Works (and Why It Breaks)
Every time your Node.js app queries the database, it needs a TCP connection. Opening a connection takes 20–100ms. At scale, doing this per-request kills performance.
A connection pool solves this by maintaining a fixed set of open connections and lending them out to requests as needed. When a request finishes, it returns the connection to the pool for the next request.
// pg (node-postgres) pool — the most common setup
import { Pool } from "pg";
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASS,
max: 10, // max open connections — default is 10
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 2000, // throw if no connection available in 2s
});The pool has a hard cap — max. If all 10 connections are in use and an 11th request arrives, it waits. If it waits longer than connectionTimeoutMillis, it throws. That's pool exhaustion — and it cascades fast.
Bug #1 — Connection Never Released (The Leak)
The most common cause of pool exhaustion. You acquire a connection manually, the code throws an error mid-query, and the connection is never returned to the pool. Repeat under traffic and you exhaust the pool in minutes.
// BAD — connection leaks on any error
app.get("/user/:id", async (req, res) => {
const client = await pool.connect(); // acquired from pool
const user = await client.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
// ^ if this throws, the line below never runs
client.release(); // never called on error — connection stuck open forever
res.json(user.rows[0]);
});// GOOD — always release in a finally block
app.get("/user/:id", async (req, res) => {
const client = await pool.connect();
try {
const user = await client.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
res.json(user.rows[0]);
} catch (err) {
res.status(500).json({ error: "Database error" });
} finally {
client.release(); // always runs — error or not
}
});If you call pool.connect(), you own the release. Always use try/finally. No exceptions.
Prefer pool.query() over pool.connect() when you don't need a transaction — the pool handles acquire and release automatically:
// pool.query() — no manual acquire/release needed
app.get("/user/:id", async (req, res) => {
const user = await pool.query(
"SELECT * FROM users WHERE id = $1",
[req.params.id]
);
res.json(user.rows[0]);
// connection automatically returned to pool
});Bug #2 — Connection Leaked Inside a Transaction
Transactions must be explicitly committed or rolled back. If an error happens mid-transaction and you don't catch it and roll back, the connection stays open, the transaction stays open, and the rows it touched stay locked. Other queries against those rows will hang waiting for a lock that never releases.
// BAD — transaction never rolled back on error
async function transferFunds(fromId, toId, amount) {
const client = await pool.connect();
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, fromId]);
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, toId]);
// ^ if this throws: transaction stays open, rows stay locked, client never released
await client.query("COMMIT");
client.release();
}// GOOD — always rollback in catch, always release in finally
async function transferFunds(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, fromId]
);
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, toId]
);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK"); // release the lock, undo partial changes
throw err;
} finally {
client.release(); // always return to pool
}
}Bug #3 — Pool Size Too Small (or Too Large)
Too small: requests queue up waiting for a connection. API latency spikes. Under burst traffic this cascades — the queue backs up faster than it drains, and requests start timing out.
Too large: you overwhelm the database server. PostgreSQL has its own connection limit (default 100). If 5 Node.js instances each open a pool of 50, you hit 250 connections — Postgres starts rejecting them.
// How to calculate the right pool size
// PostgreSQL rule of thumb: total_connections = num_cores * 2 + num_spindle_disks
// For a 4-core Postgres instance: max ~10 connections per app instance
// If you have 3 Node.js replicas behind a load balancer:
// 10 connections × 3 replicas = 30 total — well within Postgres default of 100
const pool = new Pool({
max: 10, // per Node.js process
idleTimeoutMillis: 30_000, // release idle connections after 30s
connectionTimeoutMillis: 3_000, // fail fast if pool is exhausted
});
// Check Postgres max_connections
// SELECT current_setting('max_connections');
// SELECT count(*) FROM pg_stat_activity; -- active right nowWhen running multiple Node.js replicas (Kubernetes pods, ECS tasks), multiply pool.maxby your replica count. That's the total connections hitting your database. Size accordingly.
Bug #4 — Creating a New Pool Per Request
This one is surprisingly common in tutorials and serverless examples. A new Pool is created inside a route handler or a function. Every request opens its own set of connections. The pool is never reused across requests. Connections pile up until the database runs out.
// BAD — new pool on every request
app.get("/products", async (req, res) => {
const pool = new Pool({ ... }); // opens new connections every single request
const result = await pool.query("SELECT * FROM products");
res.json(result.rows);
// pool.end() is never called — connections stay open forever
});// GOOD — one pool for the entire process lifetime
// db.ts
import { Pool } from "pg";
export const pool = new Pool({ max: 10, ... });
// routes/products.ts
import { pool } from "../db";
app.get("/products", async (req, res) => {
const result = await pool.query("SELECT * FROM products");
res.json(result.rows);
// same 10 connections shared across all requests
});Bug #5 — Not Handling connectionTimeoutMillis
When the pool is exhausted, the next pool.connect() or pool.query() will wait until connectionTimeoutMillis elapses, then throw. If this error is not caught, it surfaces as an unhandled promise rejection and crashes the request with a 500 — but the real cause (pool exhaustion) is buried.
// What the error looks like
// Error: timeout exceeded when trying to connect
// at Pool._connect (/node_modules/pg-pool/index.js:...)
// How to expose the real cause in your error handler
app.use((err, req, res, next) => {
if (err.message.includes("timeout exceeded when trying to connect")) {
console.error("[DB] Connection pool exhausted — check active connections");
// SELECT count(*), state FROM pg_stat_activity GROUP BY state;
return res.status(503).json({ error: "Service temporarily unavailable" });
}
res.status(500).json({ error: "Internal server error" });
});How to Monitor the Pool in Production
Don't wait for timeouts to discover the pool is unhealthy. Expose pool metrics on an internal endpoint and alert on them:
// Expose pool stats on an internal health endpoint
app.get("/health/db", (req, res) => {
res.json({
total: pool.totalCount, // total connections in the pool
idle: pool.idleCount, // connections waiting for a query
waiting: pool.waitingCount // requests waiting for a connection
});
});
// What to alert on:
// waiting > 0 for more than a few seconds → pool under pressure
// idle === 0 && waiting > 0 → pool exhausted
// total < max for extended period → connections leaking (not being returned)And at the Postgres level — always watch active connections directly:
-- Active connections right now
SELECT count(*), state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE datname = 'your_db_name'
GROUP BY state, wait_event_type, wait_event;
-- Long-running queries holding connections
SELECT pid, now() - query_start AS duration, query, state
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - query_start > interval '5 seconds'
ORDER BY duration DESC;If your API is timing out: (1) check pool.waitingCount— if it's climbing, the pool is exhausted. (2) Run the pg_stat_activity query — look for connections stuck in 'idle in transaction' state. Those are leaked transactions.
Quick Reference — What Each Symptom Means
Symptom Root Cause
──────────────────────────────────────────────────────────────────
API timeouts under load Pool exhausted — pool.max too small
or connections leaking
"timeout exceeded when connecting" Pool exhausted — waiting queue full
Rows locked / queries hanging Transaction not rolled back — lock held
DB shows 200+ connections New pool created per request
Connections grow over time client.release() missing on error path
idle in transaction in pg_stat Transaction started, never committed/rolled backThe One-Line Rule
One pool per process, always finally { client.release() }, always rollback in catch. Everything else in this article is a variation of violating one of these three rules.