Pooling de conexiones para aplicaciones de alto rendimiento
Configura y optimiza los pools de conexiones para Node.js de alto rendimiento: dimensionado, health checks, ciclo de vida y qué agota un pool.

Cada consulta a la base de datos requiere una conexión. Crear una nueva conexión TCP para cada consulta implica un handshake TLS, un round-trip de autenticación y la negociación del protocolo: fácilmente 50-100 ms de sobrecarga antes del primer byte de datos. A 1.000 consultas por segundo, esa sobrecarga por sí sola satura tu aplicación.
El pooling de conexiones resuelve esto manteniendo un conjunto de conexiones preestablecidas que las consultas toman prestadas y devuelven. Pero un pool mal configurado es peor que no tener pool: demasiado pequeño ahoga la aplicación, demasiado grande desborda la base de datos, y las conexiones fugadas degradan el sistema en silencio hasta que colapsa.
Por qué importa el pooling de conexiones
Sin pooling, cada petición crea y destruye una conexión a la base de datos. La sobrecarga se acumula rápidamente bajo carga.
// ❌ 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 overheadDimensionamiento del pool: la configuración crítica
El error más común es fijar el tamaño del pool igual al número de instancias de la aplicación o de CPUs. El tamaño del pool debería basarse en la capacidad de la base de datos y en las características de las consultas.
// 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,
});Health checks de las conexiones
Las conexiones obsoletas en el pool causan fallos silenciosos. Puede que la base de datos se haya reiniciado, que haya ocurrido una partición de red o que la conexión haya alcanzado un timeout del lado del servidor.
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();
}
}Uso del pool consciente de las transacciones
El patrón de pool más peligroso es fugar conexiones dentro de transacciones. Un release() olvidado en un camino de error elimina permanentemente una conexión del 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" };
});
}Monitorización de la salud del pool
El agotamiento del pool no provoca un fallo inmediato: se degrada gradualmente a medida que las peticiones se encolan esperando una conexión. Monitoriza las métricas del pool para detectar los problemas antes de que se propaguen.
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`,
};
}
}Conclusiones clave
El tamaño del pool debería calcularse a partir de la capacidad de la base de datos (cores * 2 + effective_spindles) dividida entre las instancias de la aplicación: demasiado alto desborda la base de datos, demasiado bajo ahoga la aplicación, y el total entre todas las instancias importa más que el conteo de cualquier instancia individual. Usa siempre un patrón de wrapper transaccional con try/catch/finally que garantice que client.release() se ejecuta independientemente del éxito o del fallo, porque una única conexión fugada en un camino de error reduce silenciosamente la capacidad del pool hasta que la aplicación se degrada bajo carga. Monitoriza las métricas del pool de forma continua: registra waitingCount, idleCount y totalCount como series temporales, y alerta cuando las consultas en espera superen cero, porque la presión sobre el pool es un indicador temprano de problemas de capacidad que se propagan en timeouts. Valida las conexiones antes de usarlas en caminos críticos —las conexiones pueden quedar obsoletas por reinicios de la base de datos, particiones de red o timeouts de inactividad del lado del servidor— y libera las conexiones inválidas con release(true) para que el pool las destruya y cree reemplazos nuevos.


