Skip to content

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.

4 min read
Diagram showing multiple application instances sharing a connection pool that manages connections to a database cluster

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.

tstypescript
// ❌ 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
tstypescript
// ✅ 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 overhead

Pool 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.

tstypescript
// 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.

tstypescript
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.

tstypescript
// ❌ 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();
}
tstypescript
// ✅ 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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX