Database Connection Pooling
Opening a database connection is expensive: it involves a TCP handshake, TLS negotiation, and authentication, all before a single query runs. A connection pool keeps a set of established connections alive and hands them out to requests as needed, returning them to the pool when work is done. This turns a costly per-request setup into a cheap borrow-and-return, which is essential for any Node.js service handling concurrent traffic.
Why pooling matters
Without a pool, every query opens a new connection and tears it down afterward. Under load this adds latency to each request and quickly overwhelms the database, which has a hard cap on simultaneous connections (PostgreSQL defaults to 100, MySQL to 151). Because Node.js processes many requests concurrently on a single thread, a burst of traffic can spawn hundreds of connection attempts at once.
A pool solves this by bounding the number of live connections and reusing them. Requests that arrive when all connections are busy wait briefly in a queue rather than hammering the database. The result is lower latency, predictable resource usage, and protection against connection exhaustion on the server side.
Run one pool per process and share it across your whole application. Creating a new pool per request defeats the purpose entirely and is one of the most common pooling mistakes.
Configuring pool size
Most drivers let you create a pool with a handful of options. The node-postgres (pg) driver is representative:
import pg from "pg";
const { Pool } = pg;
export const pool = new Pool({
host: process.env.PGHOST,
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
max: 10, // maximum connections in the pool
min: 0, // keep no idle connections when quiet
idleTimeoutMillis: 30_000, // close idle clients after 30s
connectionTimeoutMillis: 5_000, // fail fast if none free in 5s
});
The single most important knob is max. Bigger is not better: if every Node instance opens max connections, the total across instances must stay under the database limit. A practical formula is max = (db_connection_limit - reserved) / number_of_app_instances.
| Option | Purpose | Typical value |
|---|---|---|
max | Upper bound on live connections | 5-20 per instance |
min | Connections kept warm when idle | 0-2 |
idleTimeoutMillis | When to close surplus idle connections | 10000-30000 |
connectionTimeoutMillis | How long to wait for a free connection | 2000-10000 |
maxUses | Recycle a connection after N queries | 7500 (optional) |
Acquiring and releasing connections
For one-off queries, call pool.query() directly. The pool checks out a connection, runs the query, and returns it automatically. This is the safest pattern because there is nothing to leak.
import { pool } from "./db.js";
const { rows } = await pool.query(
"SELECT id, email FROM users WHERE active = $1",
[true],
);
console.log(rows);
When you need several queries on the same connection — for a transaction, or to use session state — check a client out explicitly and always release it in a finally block:
export async function createOrder(userId, total) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query(
"INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id",
[userId, total],
);
await client.query("COMMIT");
return rows[0].id;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release(); // return to the pool even on error
}
}
Forgetting
client.release()is the classic cause of pool exhaustion: each leaked client permanently removes one connection from the pool until none remain and every request hangs.
Handling exhaustion and timeouts
When all connections are checked out, new requests wait. If connectionTimeoutMillis elapses first, pool.connect() rejects instead of waiting forever. Catch that error and respond with a 503 rather than letting the request stall:
try {
const result = await pool.query("SELECT now()");
return result.rows[0];
} catch (err) {
if (err.message.includes("timeout")) {
console.error("Pool exhausted — too many concurrent queries");
throw new Error("Service temporarily unavailable");
}
throw err;
}
You can also observe pool health at runtime through its counters:
console.log({
total: pool.totalCount, // all connections
idle: pool.idleCount, // available right now
waiting: pool.waitingCount, // requests queued for a connection
});
Output:
{ total: 10, idle: 0, waiting: 14 }
A persistently high waitingCount means demand exceeds max. The fix is usually faster queries (add indexes, shorten transactions) rather than a larger pool — a bigger pool just moves the bottleneck to the database.
Pooling in serverless environments
Serverless platforms (AWS Lambda, Vercel, Cloudflare) break the one-pool assumption. Each concurrent invocation runs in its own isolated instance, so a pool of max: 10 per function multiplied by hundreds of concurrent invocations can blow past the database limit instantly.
Two strategies work well:
- Set
max: 1per instance and declare the pool outside the handler so it is reused across warm invocations of the same instance. - Put an external pooler in front of the database — PgBouncer or a managed equivalent such as Supabase’s pooler, Neon, or AWS RDS Proxy — and let it multiplex thousands of short-lived client connections onto a small set of real ones.
// Declared at module scope — survives across warm invocations
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
export async function handler(event) {
const { rows } = await pool.query("SELECT 1");
return { statusCode: 200, body: JSON.stringify(rows) };
// Do NOT call pool.end() — keep it alive for reuse
}
When connecting through a transaction-mode pooler like PgBouncer, disable prepared statements and server-side state that assume a sticky connection.
Best practices
- Create exactly one pool per process and import it wherever you query.
- Prefer
pool.query()for single statements; only check out a client for transactions. - Always release checked-out clients in a
finallyblock. - Size
maxagainst the database’s total limit divided across all instances, not by guesswork. - Set a
connectionTimeoutMillisso requests fail fast instead of hanging under load. - Monitor
waitingCountand query latency; fix slow queries before enlarging the pool. - In serverless, use a tiny per-instance pool and front the database with an external pooler.