Loading content...
A connection pool is deceptively simple in concept—maintain a set of reusable connections—yet the implementation details determine whether your pool becomes a performance enabler or a subtle source of production incidents.
Poorly designed pools cause connection leaks, memory exhaustion, deadlocks, stale connections serving corrupted data, and mysterious latency spikes. Well-designed pools are invisible—they simply work, efficiently managing resources while protecting both your application and the resources it consumes.
The difference lies in understanding the design considerations that separate production-grade pools from naive implementations.
By the end of this page, you will understand pool architecture patterns and their tradeoffs, critical configuration parameters and how to reason about them, connection validation strategies and when to apply each, health checking mechanisms, and how to design pools that handle failure gracefully.
Connection pools can be structured in several ways, each with distinct characteristics. Understanding these patterns helps you choose the right approach for your use case and understand the behavior of pools you're using.
Pattern 1: Simple Fixed Pool
The simplest pool maintains a fixed number of connections. All connections are created at startup and never destroyed (except for failure recovery). This approach offers predictability and simplicity at the cost of flexibility.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
/** * Fixed Pool Pattern * * Characteristics: * - All connections created at startup * - Size never changes during operation * - Simple, predictable resource usage * - May waste resources during low-load periods */class FixedConnectionPool { private connections: Connection[] = []; private available: Connection[] = []; private readonly size: number; constructor(size: number, factory: ConnectionFactory) { this.size = size; } async initialize(): Promise<void> { // Create all connections upfront for (let i = 0; i < this.size; i++) { const conn = await this.factory.create(); this.connections.push(conn); this.available.push(conn); } } async acquire(): Promise<Connection> { if (this.available.length > 0) { return this.available.pop()!; } // No available connections - wait for one to be released return this.waitForAvailable(); } release(connection: Connection): void { this.available.push(connection); this.notifyWaiters(); }} // Usage scenario:// - Known, consistent load patterns// - Dedicated database capacity per application// - Simplicity is prioritized over efficiencyPattern 2: Dynamic Elastic Pool
Elastic pools grow and shrink based on demand. They maintain a minimum number of connections for baseline performance while scaling up during high load and scaling down during quiet periods. This pattern balances resource efficiency with responsiveness.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
/** * Elastic Pool Pattern * * Characteristics: * - Scales between min and max based on demand * - Shrinks during low load (resource efficiency) * - Grows responsively to demand spikes * - More complex state management */ interface ElasticPoolConfig { minConnections: number; // Always maintain at least this many maxConnections: number; // Never exceed this limit acquireTimeout: number; // Max wait for connection idleTimeout: number; // Close idle connections after this duration reaperInterval: number; // How often to check for idle connections} class ElasticConnectionPool { private available: Connection[] = []; private inUse: Set<Connection> = new Set(); private config: ElasticPoolConfig; private factory: ConnectionFactory; constructor(config: ElasticPoolConfig, factory: ConnectionFactory) { this.config = config; this.factory = factory; } async initialize(): Promise<void> { // Create minimum connections for (let i = 0; i < this.config.minConnections; i++) { const conn = await this.factory.create(); this.available.push(conn); } // Start background reaper for idle connections this.startIdleReaper(); } async acquire(): Promise<Connection> { // Try available connection first const available = this.available.pop(); if (available) { this.inUse.add(available); return available; } // Can we create a new connection? const totalConnections = this.available.length + this.inUse.size; if (totalConnections < this.config.maxConnections) { const newConn = await this.factory.create(); this.inUse.add(newConn); return newConn; } // At max - wait for available connection with timeout return this.waitWithTimeout(this.config.acquireTimeout); } release(connection: Connection): void { this.inUse.delete(connection); // Track when connection became idle for reaper (connection as any).__lastUsed = Date.now(); this.available.push(connection); this.notifyWaiters(); } /** * Background task to close idle connections * * This allows the pool to shrink during low-load periods, * freeing database resources for other applications. */ private startIdleReaper(): void { setInterval(() => { const now = Date.now(); const totalConnections = this.available.length + this.inUse.size; // Never close below minimum if (totalConnections <= this.config.minConnections) { return; } // Find and close connections idle too long this.available = this.available.filter(conn => { const lastUsed = (conn as any).__lastUsed || 0; const idleDuration = now - lastUsed; if (idleDuration > this.config.idleTimeout) { // Don't close if we'd go below minimum const remaining = this.available.length + this.inUse.size - 1; if (remaining >= this.config.minConnections) { conn.close(); // Fire and forget return false; // Remove from array } } return true; }); }, this.config.reaperInterval); }}Pattern 3: Partitioned Pool
Partitioned pools maintain separate sub-pools for different purposes—for example, separate pools for read and write operations, or separate pools for different tenants. This enables fine-grained resource allocation and isolation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
/** * Partitioned Pool Pattern * * Use cases: * - Read/write separation (read replicas) * - Multi-tenant isolation * - Priority-based resource allocation * - Different connection configurations per workload */ interface PartitionedPoolConfig { partitions: { name: string; config: PoolConfig; factory: ConnectionFactory; }[]; defaultPartition: string;} class PartitionedConnectionPool { private pools: Map<string, ConnectionPool> = new Map(); private defaultPartition: string; constructor(config: PartitionedPoolConfig) { this.defaultPartition = config.defaultPartition; for (const partition of config.partitions) { const pool = new ElasticConnectionPool( partition.config, partition.factory ); this.pools.set(partition.name, pool); } } async acquire(partition?: string): Promise<Connection> { const poolName = partition || this.defaultPartition; const pool = this.pools.get(poolName); if (!pool) { throw new Error(`Unknown partition: ${poolName}`); } return pool.acquire(); } release(connection: Connection, partition?: string): void { const poolName = partition || this.defaultPartition; const pool = this.pools.get(poolName); pool?.release(connection); }} // Example: Read/Write Separationconst readWritePool = new PartitionedConnectionPool({ partitions: [ { name: 'write', config: { minConnections: 5, maxConnections: 20 }, factory: new PrimaryDbConnectionFactory(primaryHost) }, { name: 'read', config: { minConnections: 20, maxConnections: 100 }, factory: new ReplicaDbConnectionFactory(replicaHosts) } ], defaultPartition: 'write'}); // Usage:const writeConn = await readWritePool.acquire('write');const readConn = await readWritePool.acquire('read');| Pattern | Pros | Cons | Best For |
|---|---|---|---|
| Fixed Pool | Simple, predictable, no scaling complexity | Wastes resources at low load, can't handle unexpected spikes | Stable, predictable workloads with dedicated resources |
| Elastic Pool | Resource efficient, adapts to load | More complex, potential cold-start after scale-down | Variable workloads, shared resources, cost-sensitive environments |
| Partitioned Pool | Isolation, fine-grained control, priority support | Configuration complexity, potential underutilization per partition | Read replicas, multi-tenant, mixed criticality workloads |
Pool behavior is determined by a set of interdependent configuration parameters. Understanding each parameter—what it controls, how it interacts with others, and how to tune it—is essential for production pool management.
| Parameter | Description | Typical Range | Impact of Misconfiguration |
|---|---|---|---|
| minConnections | Minimum connections maintained even when idle | 5-20 | Too low: cold starts after idle; Too high: wasted resources |
| maxConnections | Maximum connections allowed | 20-100 per instance | Too low: request queuing; Too high: database overwhelm |
| acquireTimeout | Max time to wait for connection | 3-30 seconds | Too low: unnecessary failures; Too high: cascading slowdowns |
| idleTimeout | Time before idle connection is closed | 5-30 minutes | Too low: excessive churn; Too high: stale connections |
| maxLifetime | Maximum age of any connection | 15-30 minutes | Too low: unnecessary churn; Too high: connection staleness |
| validationTimeout | Timeout for connection validation | 1-5 seconds | Too low: false positives; Too high: slow failure detection |
| leakDetectionThreshold | Time before warning about unreturned connection | 30-120 seconds | Too low: false alarms; Too high: late leak detection |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
/** * Production Connection Pool Configuration * * This configuration represents a well-tuned pool for a typical * web application workload. Each parameter is explained with * its reasoning. */ interface ProductionPoolConfig { // === Connection Limits === /** * Minimum connections to maintain. * * Set to handle typical baseline load without acquisition latency. * Too low: cold starts when traffic increases * Too high: wasted resources during quiet periods * * Rule of thumb: Set to handle average load, not peak. */ minimumIdle: 10, /** * Maximum connections allowed. * * Must consider: * - Database max_connections / number of app instances * - Expected peak connection demand * - Memory impact on database (each connection uses memory) * * Rule of thumb: (database max_connections - headroom) / app_instances */ maximumPoolSize: 50, // === Timeouts === /** * Maximum time to wait for connection acquisition. * * If a connection isn't available within this time, fail fast. * Should be less than your request timeout to enable graceful failure. * * Rule of thumb: 50-80% of your request timeout */ connectionTimeout: 10000, // 10 seconds /** * How long connections can stay idle before being removed. * * Idle connections consume database resources. Close them * to free resources for other applications. * * Balance: Too low = connection churn; Too high = resource waste */ idleTimeout: 600000, // 10 minutes /** * Maximum lifetime of any connection. * * Even active connections should be periodically refreshed: * - Prevents memory leaks in connection state * - Ensures load balancer changes are picked up * - Gracefully handles database failovers * * Rule of thumb: Less than database wait_timeout */ maxLifetime: 1800000, // 30 minutes // === Validation === /** * Query used to validate connections. * * Simple, fast query that verifies connection is functional. * Must be specific to your database type. */ connectionTestQuery: 'SELECT 1', /** * Timeout for validation queries. * * If validation takes longer than this, consider connection dead. */ validationTimeout: 5000, // 5 seconds // === Leak Detection === /** * Time before unreturned connection triggers warning. * * Helps detect connection leaks during development. * Set higher in production to reduce log noise. */ leakDetectionThreshold: 60000, // 1 minute // === Initialization === /** * Whether to fail fast if pool can't initialize. * * If true: application won't start if database unreachable * If false: application starts, pool populates when database available * * Generally: true for critical databases, false for optional services */ initializationFailTimeout: 30000,}Pool parameters are interdependent. For example, maxLifetime should be less than database connection timeout to prevent the database from forcibly closing connections. idleTimeout should be less than maxLifetime for predictable behavior. acquireTimeout should be less than request timeout to allow graceful failure handling. Always consider these relationships when tuning.
Connections can become unusable for many reasons: network partitions, database restarts, firewall timeouts, or server-side connection cleanup. Validation strategies detect these failures before passing dead connections to application code.
The Validation Tradeoff
Every validation has cost—it adds latency and consumes resources. But insufficient validation leads to application errors and requires recovery logic. The right strategy balances these concerns.
| Strategy | When Executed | Latency Impact | Detection Speed | Use Case |
|---|---|---|---|---|
| Never Validate | Never | Zero | None (errors surface in app) | Development only; not recommended for production |
| Validate on Acquire | Before returning connection to client | Adds ~1-5ms per acquisition | Immediate | Critical systems where stale connections cause severe impact |
| Validate if Idle | Only if connection idle > threshold | Conditional ~1-5ms | Delayed for active connections | Balance of safety and performance; most common |
| Background Validation | Periodic sweep of idle connections | None on acquire | Delayed (depends on interval) | High-throughput systems prioritizing acquisition latency |
| On Error Retry | After failed operation | Only on failure path | Reactive | Supplement to other strategies; handles edge cases |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
/** * Multi-Strategy Connection Validation * * Production pools typically combine multiple strategies for * optimal balance of safety and performance. */ interface ValidationConfig { // Validate if idle longer than this duration validateIfIdleLongerThan: number; // e.g., 30 seconds // Background validation interval backgroundValidationInterval: number; // e.g., 5 minutes // Validation query validationQuery: string; // Validation timeout validationTimeout: number;} class ValidatingConnectionPool { private config: ValidationConfig; /** * Acquire with conditional validation * * Only validates if connection has been idle beyond threshold, * avoiding validation overhead for actively-used connections. */ async acquire(): Promise<Connection> { const connection = this.available.pop(); if (!connection) { return this.createOrWait(); } const idleDuration = Date.now() - connection.lastUsed; // Only validate if idle longer than threshold if (idleDuration > this.config.validateIfIdleLongerThan) { const isValid = await this.validate(connection); if (!isValid) { // Connection is dead - close and try again await this.destroyConnection(connection); return this.acquire(); // Recursive retry } } this.inUse.add(connection); return connection; } /** * Validate connection health * * Executes validation query with timeout. Any failure * (timeout, error, unexpected result) means connection is dead. */ private async validate(connection: Connection): Promise<boolean> { try { const result = await Promise.race([ connection.query(this.config.validationQuery), this.timeout(this.config.validationTimeout) ]); // Validation query should return expected result return result?.rows?.[0]?.[0] === 1; } catch (error) { // Any error means validation failed return false; } } /** * Background validation loop * * Periodically validates idle connections without affecting * acquisition latency. Removes dead connections proactively. */ private startBackgroundValidation(): void { setInterval(async () => { // Copy array to avoid mutation during iteration const idleConnections = [...this.available]; for (const connection of idleConnections) { const isValid = await this.validate(connection); if (!isValid) { // Remove from available pool const index = this.available.indexOf(connection); if (index > -1) { this.available.splice(index, 1); } await this.destroyConnection(connection); // Replenish if below minimum await this.ensureMinimumConnections(); } } }, this.config.backgroundValidationInterval); } /** * On-error retry wrapper * * If operation fails due to connection issue, validate and retry * with a fresh connection. Handles edge cases missed by other strategies. */ async executeWithRetry<T>( operation: (conn: Connection) => Promise<T>, maxRetries: number = 1 ): Promise<T> { let lastError: Error | undefined; for (let attempt = 0; attempt <= maxRetries; attempt++) { const connection = await this.acquire(); try { const result = await operation(connection); this.release(connection); return result; } catch (error) { lastError = error as Error; // Is this a connection error or application error? if (this.isConnectionError(error)) { // Connection is bad - destroy it await this.destroyConnection(connection); continue; // Retry with new connection } else { // Application error - release connection and throw this.release(connection); throw error; } } } throw lastError; } private isConnectionError(error: any): boolean { // Database-specific connection error detection const connectionErrorCodes = [ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'PROTOCOL_CONNECTION_LOST', '08003', '08006' ]; return connectionErrorCodes.some(code => error?.code === code || error?.message?.includes(code) ); }}The 'validate if idle longer than' threshold is crucial. Set it shorter than the minimum of: (1) Database connection timeout, (2) Firewall idle connection timeout, (3) Load balancer connection timeout. A common safe value is 30 seconds—short enough to catch most stale connections, long enough to avoid excessive validation overhead.
Beyond validation, pools must manage the complete lifecycle of connections: creation, preparation for use, cleanup after use, and graceful destruction. Each stage presents design decisions that affect pool behavior.
Connection Preparation
When a connection is acquired, it may need preparation before use. This includes setting session-level configurations, timezone settings, or transaction isolation levels.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
/** * Connection Lifecycle Management * * Hooks allow customization of connection behavior at each * lifecycle stage without modifying pool core logic. */ interface ConnectionLifecycleHooks { /** * Called immediately after connection is created. * Use for one-time setup that persists across uses. */ onCreate?: (connection: Connection) => Promise<void>; /** * Called before connection is given to a client. * Use for per-acquisition setup (e.g., set session variables). */ onAcquire?: (connection: Connection) => Promise<void>; /** * Called when connection is returned to pool. * Use for cleanup (e.g., rollback uncommitted transactions). */ onRelease?: (connection: Connection) => Promise<void>; /** * Called before connection is destroyed. * Use for final cleanup (e.g., notify monitoring systems). */ onDestroy?: (connection: Connection) => Promise<void>; /** * Called when connection validation fails. * Use for logging, metrics, alerting. */ onValidationFailure?: (connection: Connection, error: Error) => void;} class ManagedConnectionPool { private hooks: ConnectionLifecycleHooks; constructor(config: PoolConfig, hooks: ConnectionLifecycleHooks) { this.hooks = hooks; } private async createConnection(): Promise<Connection> { const connection = await this.factory.create(); // Run onCreate hook for one-time setup if (this.hooks.onCreate) { await this.hooks.onCreate(connection); } return connection; } async acquire(): Promise<Connection> { const connection = await this.getAvailableConnection(); // Run onAcquire hook for per-use setup if (this.hooks.onAcquire) { await this.hooks.onAcquire(connection); } return connection; } async release(connection: Connection): Promise<void> { // Run onRelease hook for cleanup if (this.hooks.onRelease) { try { await this.hooks.onRelease(connection); } catch (error) { // Cleanup failed - connection may be corrupted // Destroy rather than return to pool await this.destroyConnection(connection); return; } } this.returnToPool(connection); } private async destroyConnection(connection: Connection): Promise<void> { // Run onDestroy hook for final cleanup if (this.hooks.onDestroy) { await this.hooks.onDestroy(connection); } await connection.close(); }} // Example: Production lifecycle hooks for PostgreSQLconst postgresHooks: ConnectionLifecycleHooks = { onCreate: async (conn) => { // Set connection-level defaults (persist across uses) await conn.query("SET timezone = 'UTC'"); await conn.query("SET statement_timeout = '30s'"); }, onAcquire: async (conn) => { // Reset session state for clean use await conn.query("RESET ALL"); await conn.query("SET application_name = 'my-service'"); }, onRelease: async (conn) => { // Ensure no uncommitted transactions // ROLLBACK is safe even if no transaction active await conn.query("ROLLBACK"); // Clear any temporary tables or session state await conn.query("DISCARD TEMP"); }, onDestroy: async (conn) => { // Close cleanly - notify database await conn.query("SELECT pg_terminate_backend(pg_backend_pid())"); }, onValidationFailure: (conn, error) => { // Log for monitoring console.error(`Connection validation failed: ${error.message}`); metrics.increment('pool.validation.failures'); }};When a connection is returned to the pool, ensure no uncommitted transactions remain. A connection returned mid-transaction will hold locks, potentially causing deadlocks or blocking other operations. Always ROLLBACK or verify transaction state in the onRelease hook. This is the most common source of pool-related bugs.
Connection Wrapper Pattern
To ensure connections are properly returned to the pool (even when code forgets to release), production pools often use wrapper patterns that intercept close() calls and release instead of actually closing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
/** * Connection Wrapper * * Wraps raw connections to: * 1. Intercept close() to return to pool instead * 2. Track usage for leak detection * 3. Add timeout protection */ class PooledConnectionWrapper implements Connection { private inner: Connection; private pool: ConnectionPool; private acquiredAt: number; private leakTimer?: NodeJS.Timeout; constructor( connection: Connection, pool: ConnectionPool, leakThreshold: number ) { this.inner = connection; this.pool = pool; this.acquiredAt = Date.now(); // Set up leak detection this.leakTimer = setTimeout(() => { console.warn( 'Connection held for >' + leakThreshold + 'ms. ' + 'Possible leak. Stack trace:', new Error().stack ); }, leakThreshold); } async query(sql: string): Promise<any> { return this.inner.query(sql); } /** * Intercept close() - return to pool instead of closing */ async close(): Promise<void> { // Clear leak detection timer if (this.leakTimer) { clearTimeout(this.leakTimer); } // Return to pool (not actually close) this.pool.release(this.inner); } /** * Force actual close (for pool internal use only) */ async forceClose(): Promise<void> { if (this.leakTimer) { clearTimeout(this.leakTimer); } await this.inner.close(); } /** * Check if connection has been held too long */ get heldDuration(): number { return Date.now() - this.acquiredAt; }} // Usage ensures connections always return to pool:async function handleRequest(pool: ConnectionPool) { const connection = await pool.acquire(); try { await connection.query('SELECT * FROM users'); // ... more operations } finally { // This calls our intercepted close(), returning to pool await connection.close(); }}Pools must handle various failure scenarios gracefully. Poor failure handling transforms pools from reliability assets into liability multipliers. Every failure mode requires explicit design.
Failure Taxonomy
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
/** * Resilient Connection Creation * * Handles transient failures during connection establishment * using exponential backoff with jitter. */ interface RetryConfig { maxAttempts: number; initialDelayMs: number; maxDelayMs: number; backoffMultiplier: number;} class ResilientConnectionFactory { private factory: ConnectionFactory; private retryConfig: RetryConfig; async create(): Promise<Connection> { let lastError: Error; let delay = this.retryConfig.initialDelayMs; for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt++) { try { return await this.factory.create(); } catch (error) { lastError = error as Error; // Is this a retryable error? if (!this.isRetryable(error)) { throw error; // Don't retry auth failures, etc. } // Log the failure console.warn( `Connection creation failed (attempt ${attempt}/${this.retryConfig.maxAttempts}): ${error.message}` ); // Wait before retry (with jitter) const jitter = Math.random() * delay * 0.1; await this.sleep(delay + jitter); // Exponential backoff for next attempt delay = Math.min( delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelayMs ); } } // All attempts exhausted throw new Error( `Failed to create connection after ${this.retryConfig.maxAttempts} attempts. ` + `Last error: ${lastError!.message}` ); } private isRetryable(error: any): boolean { // Retry network/timeout errors, but not auth/config errors const retryableCodes = [ 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN' ]; const nonRetryableCodes = [ 'EACCES', 'AUTH_FAILED', 'INVALID_PASSWORD', 'DATABASE_NOT_FOUND' ]; if (nonRetryableCodes.some(c => error?.code?.includes(c))) { return false; } return retryableCodes.some(c => error?.code?.includes(c)); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
/** * Pool Exhaustion Handling * * When all connections are in use and we're at max capacity, * handle the situation gracefully with queuing and clear errors. */ interface WaitingClient { resolve: (conn: Connection) => void; reject: (error: Error) => void; enqueuedAt: number;} class PoolWithExhaustionHandling { private waitQueue: WaitingClient[] = []; private config: PoolConfig; async acquire(): Promise<Connection> { // Try to get connection immediately const immediate = this.tryAcquireImmediate(); if (immediate) return immediate; // Pool exhausted - check queue capacity if (this.waitQueue.length >= this.config.maxWaitQueueSize) { // Queue is also full - fail immediately throw new PoolExhaustedError( `Connection pool exhausted. ` + `Active: ${this.inUse.size}/${this.config.maxConnections}, ` + `Waiting: ${this.waitQueue.length}/${this.config.maxWaitQueueSize}. ` + `Consider increasing pool size or reducing connection hold time.` ); } // Wait in queue with timeout return new Promise((resolve, reject) => { const client: WaitingClient = { resolve, reject, enqueuedAt: Date.now() }; this.waitQueue.push(client); // Set timeout const timeout = setTimeout(() => { // Remove from queue const index = this.waitQueue.indexOf(client); if (index > -1) { this.waitQueue.splice(index, 1); } // Reject with timeout error reject(new ConnectionTimeoutError( `Timed out waiting for connection after ${this.config.acquireTimeout}ms. ` + `Pool status: ${this.inUse.size} active, ${this.available.length} available, ` + `${this.waitQueue.length} waiting.` )); }, this.config.acquireTimeout); // Clear timeout if resolved const originalResolve = resolve; client.resolve = (conn) => { clearTimeout(timeout); originalResolve(conn); }; }); } release(connection: Connection): void { this.inUse.delete(connection); // Check if anyone is waiting (FIFO order) if (this.waitQueue.length > 0) { const waiter = this.waitQueue.shift()!; this.inUse.add(connection); waiter.resolve(connection); } else { this.available.push(connection); } }} // Custom error types for clear diagnosisclass PoolExhaustedError extends Error { constructor(message: string) { super(message); this.name = 'PoolExhaustedError'; }} class ConnectionTimeoutError extends Error { constructor(message: string) { super(message); this.name = 'ConnectionTimeoutError'; }}Connection pools are inherently concurrent data structures. Multiple threads (or async operations) simultaneously acquire, use, and release connections. Without proper synchronization, race conditions lead to double-lending, connection corruption, or resource leaks.
Critical Race Conditions
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
/** * Thread-Safe Pool Operations * * In Node.js, we don't have true multi-threading, but async * operations can still interleave. We must ensure atomic * state transitions. */ class ThreadSafePool { private available: Connection[] = []; private inUse: Set<Connection> = new Set(); private waitQueue: WaitingClient[] = []; /** * Atomic acquire operation * * The key insight: in Node.js, synchronous code runs atomically. * We must ensure all state checks and transitions happen * synchronously or are properly sequenced. */ async acquire(): Promise<Connection> { // This synchronous block is atomic (no await) const connection = this.available.pop(); if (connection) { // Move to inUse synchronously (atomic with pop) this.inUse.add(connection); // Validation is async, but connection is already "claimed" const isValid = await this.validate(connection); if (!isValid) { // Remove from inUse and destroy this.inUse.delete(connection); await this.destroyConnection(connection); // Retry acquisition return this.acquire(); } return connection; } // No available connection - can we create? // This check must be synchronous with the creation decision const totalConnections = this.available.length + this.inUse.size; if (totalConnections < this.config.maxConnections) { // "Reserve" a slot by creating a pending marker // This prevents other acquire() calls from also creating this.inUse.add(null as any); // Placeholder try { const newConn = await this.factory.create(); // Replace placeholder with real connection this.inUse.delete(null as any); this.inUse.add(newConn); return newConn; } catch (error) { // Remove placeholder on failure this.inUse.delete(null as any); throw error; } } // At capacity - wait return this.waitForConnection(); } /** * Atomic release operation */ release(connection: Connection): void { // All synchronous - atomic if (!this.inUse.has(connection)) { // Double-release protection console.warn('Attempted to release connection not in use'); return; } this.inUse.delete(connection); // Give to waiter or return to pool - must be atomic decision if (this.waitQueue.length > 0) { const waiter = this.waitQueue.shift()!; this.inUse.add(connection); waiter.resolve(connection); } else { this.available.push(connection); } }} /** * For multi-threaded environments (Java, Go, etc.), * use proper synchronization primitives: */ /*// Java example with synchronized blocks:public synchronized Connection acquire() { while (available.isEmpty() && inUse.size() >= maxSize) { wait(); // Release lock while waiting } Connection conn = available.poll(); if (conn != null) { inUse.add(conn); return conn; } // Create new connection (still synchronized) Connection newConn = factory.create(); inUse.add(newConn); return newConn;} public synchronized void release(Connection conn) { inUse.remove(conn); available.offer(conn); notifyAll(); // Wake waiting acquirers}*/JavaScript's single-threaded event loop simplifies pool implementation. Synchronous code blocks are atomic—there's no preemption. The challenge shifts to ensuring state transitions complete before yielding to the event loop (before any 'await'). Well-designed pools sequence their synchronous state updates carefully.
We've explored the critical design decisions that determine pool effectiveness. Let's consolidate the key insights:
What's next:
With pool design principles established, we'll turn to pool sizing—the art and science of determining how many connections to maintain. The next page explores pool size calculation, dynamic sizing strategies, and the relationship between pool size and system capacity.
You now understand the key design considerations for production connection pools: architecture patterns, configuration parameters, validation strategies, lifecycle management, failure handling, and concurrency safety. Next, we'll tackle pool sizing—determining the right number of connections for your workload.