home /
writings /
thoughts /
courses /
research /
projects /
Back to writings

Practical Nuances Around Connection Pools and DB Proxies

Dec 18, 2025 | 10 min read

tl;dr: Connection pool sizing, lifecycle management, DB proxy benefits, pool exhaustion scenarios, and production-ready patterns for database connectivity.

Connection pools seem simple: reuse database connections instead of creating new ones. But in production, they’re a source of subtle bugs, performance issues, and outages.

Here’s what you need to know beyond the basics.

Why Connection Pools Exist

Creating a database connection is expensive:

  • TCP handshake
  • SSL/TLS negotiation
  • Authentication
  • Session initialization

For a high-traffic application making thousands of queries per second, creating connections on-demand would be catastrophic.

Connection pools solve this by maintaining a set of pre-established connections that can be reused.

Connection Pool Architecture

graph TB
    App[Application<br/>Requests] --> Pool[Connection Pool<br/>Max: 20, Min: 5, Idle: 10]
    Pool -->|Active: 8| DB[(Database<br/>Max: 100, Current: 13)]
    Pool -->|Idle: 5| Idle[Idle Connections]
    Pool -->|Available: 7| Available[Available Slots]
    
    Pool --> Monitor[Pool Management<br/>Lifecycle, Health Checks,<br/>Timeout, Leak Detection]

Connection Pool Architecture

A connection pool manages connections in three states:

1. Active: Currently executing a query 2. Idle: Available for use, waiting in the pool 3. Creating/Closing: In transition between states

Key Configuration Parameters

const poolConfig = {
  max: 20,           // Maximum connections
  min: 5,            // Minimum idle connections
  idleTimeoutMillis: 30000,  // Close idle after 30s
  connectionTimeoutMillis: 2000,  // Wait 2s for connection
  maxLifetime: 3600000  // Close connections after 1 hour
};

Why these matter:

  • max: Too low → pool exhaustion. Too high → database overload
  • min: Keeps warm connections ready, but uses memory
  • idleTimeoutMillis: Frees unused connections, but creates churn
  • connectionTimeoutMillis: How long to wait when pool is full
  • maxLifetime: Prevents stale connections, but causes reconnection overhead

Sizing Your Connection Pool

The right size depends on your workload, but here’s a starting point:

Formula:

pool_size = (expected_concurrent_requests × avg_query_time) / target_response_time

Example:

  • 100 concurrent requests
  • Average query time: 50ms
  • Target response time: 200ms
pool_size = (100 × 0.05) / 0.2 = 25 connections

But consider:

  • Not all requests hit the database
  • Some queries are faster, some slower
  • You need headroom for traffic spikes

Practical approach:

  1. Start with max = 20 for most applications
  2. Monitor pool utilization
  3. Adjust based on:
    • Wait times (if high → increase pool)
    • Database connection count (if hitting DB limits → decrease pool)
    • Memory usage (each connection uses memory)

Connection Lifecycle Management

Connections go through a lifecycle:

Connection Lifecycle

stateDiagram-v2
    [*] --> Idle: Created
    Idle --> Active: Request
    Active --> Idle: Done
    Idle --> Validating: Idle Timeout
    Validating --> Idle: Valid
    Validating --> Closing: Invalid
    Active --> Closing: Error/Timeout
    Closing --> [*]
    
    Note right of Idle
        Available in pool
        Ready for use
    end note
    
    Note right of Active
        Executing query
        In use
    end note

States:

  1. IDLE: Available in pool
  2. ACTIVE: Executing query
  3. VALIDATING: Health check before reuse
  4. CREATING: New connection being established
  5. CLOSING: Being cleaned up

Critical transitions:

  • IDLE → ACTIVE: When request needs a connection
  • ACTIVE → IDLE: When query completes (must happen!)
  • IDLE → VALIDATING: Before reuse (catches stale connections)
  • VALIDATING → IDLE: Connection is healthy
  • VALIDATING → CLOSING: Connection failed health check

Health Checks

Idle connections can go stale:

  • Database restarted
  • Network issues
  • Connection timeout

Always validate before reuse:

class ConnectionPool {
  async getConnection() {
    let conn = this.idleConnections.pop();
    
    // Validate before reuse
    if (conn && !await this.isHealthy(conn)) {
      await this.closeConnection(conn);
      conn = null;
    }
    
    // Create new if needed
    if (!conn) {
      conn = await this.createConnection();
    }
    
    return conn;
  }
  
  async isHealthy(conn) {
    try {
      await conn.query('SELECT 1');
      return true;
    } catch (error) {
      return false;
    }
  }
}

Pool Exhaustion: The Silent Killer

Pool exhaustion happens when all connections are in use and new requests must wait (or timeout).

Pool Exhaustion Scenarios

Normal Operation:

  • Active: 8 connections
  • Idle: 5 connections
  • Available: 7 slots
  • Status: Healthy, requests served immediately

Exhausted State:

  • Active: 20 connections (MAX)
  • Idle: 0 connections
  • Waiting: 15 requests queued
  • Status: Pool full, new requests wait or timeout

Common Causes

1. Connection Leaks

The most common cause: connections not returned to the pool.

// BAD: Connection leak
async function badFunction() {
  const conn = await pool.getConnection();
  await conn.query('SELECT * FROM users');
  // Forgot to release! Connection is lost forever
}

// GOOD: Always release
async function goodFunction() {
  const conn = await pool.getConnection();
  try {
    await conn.query('SELECT * FROM users');
  } finally {
    conn.release(); // Always release, even on error
  }
}

2. Long-Running Queries

Queries that take too long hold connections:

// BAD: No timeout
await conn.query('SELECT * FROM huge_table');

// GOOD: Timeout protection
await conn.query({
  sql: 'SELECT * FROM huge_table',
  timeout: 5000  // 5 second timeout
});

3. Traffic Spikes

Sudden increases in traffic can exhaust the pool:

// Solution: Circuit breaker
class PoolWithCircuitBreaker {
  async getConnection() {
    if (this.circuitBreaker.isOpen()) {
      throw new Error('Pool exhausted, circuit open');
    }
    
    try {
      return await this.pool.getConnection({
        timeout: 2000
      });
    } catch (error) {
      this.circuitBreaker.recordFailure();
      throw error;
    }
  }
}

4. Too Small Pool

Pool size doesn’t match workload:

// Monitor and alert
const metrics = {
  activeConnections: pool.activeConnections,
  idleConnections: pool.idleConnections,
  waitingRequests: pool.waitingRequests,
  waitTime: pool.averageWaitTime
};

if (metrics.waitingRequests > 10) {
  alert('Pool may be too small!');
}

Detecting Exhaustion

Monitor these metrics:

// Key metrics to track
const poolMetrics = {
  // Current state
  active: pool.activeConnections,
  idle: pool.idleConnections,
  waiting: pool.waitingRequests,
  
  // Rates
  acquisitionRate: pool.connectionsAcquiredPerSecond,
  releaseRate: pool.connectionsReleasedPerSecond,
  
  // Timing
  averageWaitTime: pool.averageWaitTime,
  maxWaitTime: pool.maxWaitTime,
  
  // Errors
  timeoutErrors: pool.timeoutErrorCount,
  acquisitionErrors: pool.acquisitionErrorCount
};

Alert when:

  • waitingRequests > 5 for extended period
  • averageWaitTime > 1000ms
  • timeoutErrors increasing

Database Proxies: The Next Level

Database proxies sit between your application and database, providing additional functionality.

Database Proxy Architecture

graph TB
    App1[App 1] --> Proxy[Database Proxy<br/>Connection Pooling<br/>Query Routing<br/>Load Balancing<br/>Failover & Health Checks]
    App2[App 2] --> Proxy
    App3[App 3] --> Proxy
    
    Proxy -->|Write| Primary[(Primary DB<br/>Read/Write)]
    Proxy -->|Read| Replica1[(Replica 1<br/>Read Only)]
    Proxy -->|Read| Replica2[(Replica 2<br/>Read Only)]

What Proxies Provide

1. Connection Pooling

Proxies maintain their own connection pools, reducing the number of connections to the database:

Without Proxy:
App 1 → 20 connections → DB
App 2 → 20 connections → DB
App 3 → 20 connections → DB
Total: 60 connections to DB

With Proxy:
App 1 → Proxy (20 connections)
App 2 → Proxy (20 connections)  → Proxy Pool (30) → DB
App 3 → Proxy (20 connections)
Total: 30 connections to DB

2. Read/Write Splitting

Automatically route reads to replicas, writes to primary:

// Proxy handles routing automatically
await db.query('SELECT * FROM users');  // → Replica
await db.query('INSERT INTO users...'); // → Primary

3. Load Balancing

Distribute reads across multiple replicas:

Routing Strategies:

  • Round Robin: Distribute evenly, simple to implement
  • Least Connections: Route to least busy, better load balance
  • Geographic: Route by region, lower latency
  • Weighted: Route by capacity, handle different server sizes

4. Failover

Automatic failover when primary fails:

// Proxy handles failover transparently
// Your code doesn't change
await db.query('SELECT * FROM users');
// If primary fails, proxy routes to replica automatically

5. Query Analysis

Proxies can analyze and optimize queries:

  • Slow query detection
  • Query caching
  • SQL injection protection
  • Query rewriting

Proxy Trade-offs

Benefits:

  • Reduced database connections
  • Automatic failover
  • Read/write splitting
  • Centralized connection management

Costs:

  • Additional network hop (latency)
  • Another component to manage
  • Potential single point of failure
  • More complex debugging

When to use:

  • Multiple applications sharing a database
  • Need for read/write splitting
  • Complex failover requirements
  • Want centralized connection management

Production Patterns

Pattern 1: Graceful Degradation

When pool is exhausted, don’t just fail. Degrade gracefully:

async function getConnectionWithFallback() {
  try {
    return await pool.getConnection({ timeout: 1000 });
  } catch (error) {
    if (error.code === 'POOL_EXHAUSTED') {
      // Fallback: Use cached data or return error response
      return { fallback: true, data: getCachedData() };
    }
    throw error;
  }
}

Pattern 2: Connection Timeouts

Always set timeouts:

// Query timeout
await conn.query({
  sql: 'SELECT * FROM users',
  timeout: 5000  // 5 seconds
});

// Connection acquisition timeout
const conn = await pool.getConnection({
  timeout: 2000  // Wait max 2 seconds
});

Pattern 3: Monitoring and Alerting

Monitor pool health:

class PoolMonitor {
  constructor(pool) {
    this.pool = pool;
    setInterval(() => this.checkHealth(), 5000);
  }
  
  checkHealth() {
    const metrics = {
      utilization: this.pool.activeConnections / this.pool.maxConnections,
      waitTime: this.pool.averageWaitTime,
      waiting: this.pool.waitingRequests
    };
    
    // Alert if unhealthy
    if (metrics.utilization > 0.9) {
      this.alert('Pool utilization high!');
    }
    
    if (metrics.waitTime > 1000) {
      this.alert('Connection wait time high!');
    }
  }
}

Pattern 4: Leak Detection

Detect connection leaks:

class LeakDetector {
  constructor(pool) {
    this.activeConnections = new Map();
    this.pool = pool;
    
    // Track connections
    const originalGet = pool.getConnection.bind(pool);
    pool.getConnection = async (...args) => {
      const conn = await originalGet(...args);
      this.activeConnections.set(conn.id, {
        conn,
        stack: new Error().stack,
        timestamp: Date.now()
      });
      return conn;
    };
    
    // Check for leaks every minute
    setInterval(() => this.checkLeaks(), 60000);
  }
  
  checkLeaks() {
    const now = Date.now();
    for (const [id, info] of this.activeConnections) {
      const age = now - info.timestamp;
      if (age > 60000) {  // Connection held > 1 minute
        console.error('Potential leak:', {
          connectionId: id,
          age,
          stack: info.stack
        });
      }
    }
  }
}

Common Pitfalls

1. Not Releasing Connections

Always use try/finally:

// BAD
const conn = await pool.getConnection();
await conn.query('...');
conn.release();  // Not called if error!

// GOOD
const conn = await pool.getConnection();
try {
  await conn.query('...');
} finally {
  conn.release();  // Always called
}

2. Sharing Connections Across Requests

Don’t share connections:

// BAD: Global connection
let globalConn = null;

async function handler() {
  if (!globalConn) {
    globalConn = await pool.getConnection();
  }
  await globalConn.query('...');
}

// GOOD: Get connection per request
async function handler() {
  const conn = await pool.getConnection();
  try {
    await conn.query('...');
  } finally {
    conn.release();
  }
}

3. Ignoring Timeouts

Always set timeouts:

// BAD: No timeout
await conn.query('SELECT * FROM huge_table');

// GOOD: With timeout
await conn.query({
  sql: 'SELECT * FROM huge_table',
  timeout: 5000
});

4. Wrong Pool Size

Size matters:

// BAD: Too small
const pool = new Pool({ max: 5 });  // Exhausts under load

// BAD: Too large
const pool = new Pool({ max: 1000 });  // Overwhelms database

// GOOD: Right-sized
const pool = new Pool({ 
  max: 20,  // Based on your workload
  min: 5
});

Key Takeaways

  1. Size your pool correctly: Based on concurrent requests and query times
  2. Always release connections: Use try/finally blocks
  3. Set timeouts: On queries and connection acquisition
  4. Monitor pool health: Track utilization, wait times, errors
  5. Detect leaks: Track connection lifetime
  6. Consider proxies: For multi-app scenarios or read/write splitting
  7. Handle exhaustion gracefully: Circuit breakers, fallbacks

Connection pools are simple in concept but complex in practice. The difference between a working pool and a production-ready one is attention to these details.

Monitor, measure, and adjust. Your pool configuration should evolve with your workload.

Optimizing database connectivity? I provide architecture reviews, performance tuning, and production-ready patterns for connection management. Let's discuss your setup.

P.S. Follow me on Twitter where I share engineering insights, system design patterns, and technical leadership perspectives.

Enjoyed this? Support my work

Buy me a coffee