Connection Pooling for High-Throughput Applications
Configure and tune database connection pools for high-throughput Node.js: pool sizing, health checks, connection lifecycle and what exhausts a pool.

Every database query requires a connection. Creating a fresh TCP connection for each query means a TLS handshake, authentication round-trip, and protocol negotiation—easily 50-100ms of overhead before the first byte of data. At 1,000 queries per second, that overhead alone saturates your application.
Connection pooling solves this by maintaining a set of pre-established connections that queries borrow and return. But a misconfigured pool is worse than no pool at all: too small starves the application, too large overwhelms the database, and leaked connections silently degrade until the system collapses.
Why Connection Pooling Matters
Without pooling, every request creates and destroys a database connection. The overhead compounds quickly under load.
// ❌ New connection per query
import { Client } from "pg";
async function getUser(id: string) {
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect(); // ~50-100ms overhead
const result = await client.query(
"SELECT * FROM users WHERE id = $1",
[id]
);
await client.end(); // Connection destroyed
return result.rows[0];
}
// At 500 req/s: 500 connections created and
// destroyed per second, each with handshake overhead// ✅ Shared connection pool
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
async function getUser(id: string) {
const result = await pool.query(
"SELECT * FROM users WHERE id = $1",
[id]
);
return result.rows[0];
// Connection returned to pool automatically
}
// At 500 req/s: 20 connections handle all traffic,
// pre-established with zero per-request overheadPool Sizing: The Critical Configuration
The most common mistake is setting pool size equal to the number of application instances or CPUs. Pool size should be based on the database's capacity and query characteristics.
// Pool sizing formula:
// optimal_pool_size = (core_count * 2) + disk_spindles
// For SSDs: roughly core_count * 2 + 1
//
// PostgreSQL with 4 cores:
// (4 * 2) + 1 = 9 connections per database
// Not per application instance — TOTAL across
// all instances
interface PoolConfig {
max: number;
min: number;
idleTimeoutMillis: number;
connectionTimeoutMillis: number;
maxUses: number;
allowExitOnIdle: boolean;
}
function createOptimizedPool(
instanceCount: number,
dbCores: number
): PoolConfig {
// Total optimal connections for this database
const totalOptimal = dbCores * 2 + 1;
// Divide across application instances
const perInstance = Math.max(
2,
Math.floor(totalOptimal / instanceCount)
);
return {
max: perInstance,
min: Math.max(1, Math.floor(perInstance / 4)),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
// Recycle connections after N uses to prevent
// memory leaks in long-running connections
maxUses: 7500,
allowExitOnIdle: true,
};
}
// 3 app instances, database with 4 cores
// Total optimal: 9, per instance: 3
const config = createOptimizedPool(3, 4);
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
...config,
});Connection Health Checks
Stale connections in the pool cause silent failures. The database might have restarted, a network partition might have occurred, or the connection might have hit a server-side timeout.
import { Pool, PoolClient } from "pg";
class ResilientPool {
private pool: Pool;
constructor(connectionString: string, max: number) {
this.pool = new Pool({
connectionString,
max,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
// Handle pool-level errors
this.pool.on("error", (err: Error) => {
console.error(
"Unexpected pool error:",
err.message
);
});
// Log when connections are created/removed
this.pool.on("connect", () => {
console.debug(
`Pool connection created. ` +
`Total: ${this.pool.totalCount}, ` +
`Idle: ${this.pool.idleCount}`
);
});
this.pool.on("remove", () => {
console.debug(
`Pool connection removed. ` +
`Total: ${this.pool.totalCount}`
);
});
}
async getConnection(): Promise<PoolClient> {
const client = await this.pool.connect();
// Validate connection before returning
try {
await client.query("SELECT 1");
} catch {
client.release(true); // Destroy bad connection
// Pool will create a fresh replacement
return this.pool.connect();
}
return client;
}
async query<T>(
text: string,
params?: unknown[]
): Promise<T[]> {
const client = await this.getConnection();
try {
const result = await client.query(text, params);
return result.rows;
} finally {
client.release();
}
}
getStats() {
return {
total: this.pool.totalCount,
idle: this.pool.idleCount,
waiting: this.pool.waitingCount,
};
}
async shutdown(): Promise<void> {
await this.pool.end();
}
}Transaction-Aware Pool Usage
The most dangerous pool pattern is leaking connections inside transactions. A forgotten release() in an error path permanently removes a connection from the pool.
// ❌ Connection leak on error
async function transferFunds(
from: string,
to: string,
amount: number
) {
const client = await pool.connect();
await client.query("BEGIN");
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, from]
);
// If this throws, client is never released!
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, to]
);
await client.query("COMMIT");
client.release();
}// ✅ Safe transaction wrapper
async function withTransaction<T>(
pool: Pool,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release(); // Always releases
}
}
// Usage — impossible to leak
async function transferFunds(
from: string,
to: string,
amount: number
) {
return withTransaction(pool, async (client) => {
const { rows } = await client.query(
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE",
[from]
);
if (rows[0].balance < amount) {
throw new Error("Insufficient funds");
// ROLLBACK and release happen automatically
}
await client.query(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[amount, from]
);
await client.query(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
[amount, to]
);
return { from, to, amount, status: "completed" };
});
}Monitoring Pool Health
Pool exhaustion doesn't crash immediately—it degrades gradually as requests queue up waiting for connections. Monitor pool metrics to catch problems before they cascade.
import { Pool } from "pg";
class MonitoredPool {
private pool: Pool;
private metrics = {
queriesExecuted: 0,
queryErrors: 0,
connectionWaits: 0,
maxWaitingObserved: 0,
};
constructor(config: Record<string, unknown>) {
this.pool = new Pool(config);
this.startMetricsCollection();
}
private startMetricsCollection() {
setInterval(() => {
const stats = {
total: this.pool.totalCount,
idle: this.pool.idleCount,
waiting: this.pool.waitingCount,
...this.metrics,
};
// Track high-water mark
if (
stats.waiting > this.metrics.maxWaitingObserved
) {
this.metrics.maxWaitingObserved = stats.waiting;
}
// Alert on pool pressure
if (stats.waiting > 0) {
this.metrics.connectionWaits++;
console.warn(
`⚠️ Pool pressure: ` +
`${stats.waiting} queries waiting, ` +
`${stats.idle}/${stats.total} idle`
);
}
if (stats.idle === 0 && stats.waiting > 5) {
console.error(
`🚨 Pool exhaustion risk: ` +
`0 idle connections, ` +
`${stats.waiting} waiting`
);
}
}, 5000);
}
async query<T>(
text: string,
params?: unknown[]
): Promise<T[]> {
const start = performance.now();
try {
const result = await this.pool.query(
text,
params
);
this.metrics.queriesExecuted++;
const duration = performance.now() - start;
if (duration > 1000) {
console.warn(
`Slow query (${Math.round(duration)}ms): ` +
`${text.slice(0, 100)}`
);
}
return result.rows;
} catch (error) {
this.metrics.queryErrors++;
throw error;
}
}
getHealthStatus(): {
healthy: boolean;
details: string;
} {
const waiting = this.pool.waitingCount;
const total = this.pool.totalCount;
const idle = this.pool.idleCount;
if (waiting > total) {
return {
healthy: false,
details:
`Pool exhausted: ${waiting} waiting, ` +
`${idle}/${total} idle`,
};
}
return {
healthy: true,
details:
`${idle}/${total} idle, ` +
`${waiting} waiting`,
};
}
}Key Takeaways
Pool size should be calculated from the database's capacity (cores * 2 + effective_spindles) divided across application instances—setting it too high overwhelms the database while too low starves the application, and the total across all instances matters more than any single instance's count. Always use a transaction wrapper pattern with try/catch/finally that guarantees client.release() runs regardless of success or failure, because a single leaked connection in an error path silently reduces pool capacity until the application degrades under load. Monitor pool metrics continuously: track waitingCount, idleCount, and totalCount as time-series data, and alert when waiting queries exceed zero because pool pressure is a leading indicator of capacity problems that cascade into timeouts. Validate connections before use in critical paths—connections can go stale from database restarts, network partitions, or server-side idle timeouts—and release invalid connections with release(true) so the pool destroys them and creates fresh replacements.


