Connection Pooling

Optimizing Database Performance in Node.js Applications

Understanding Database Connection Pooling

Database connection pooling is a technique used to manage and reuse database connections efficiently. Instead of creating a new connection for each database operation, connection pooling maintains a "pool" of pre-established connections that can be reused across multiple requests.

Imagine a busy restaurant with limited seating. Without a reservation system, each customer would have to wait for a table to be set up from scratch when they arrive, leading to delays. Connection pooling is like having tables always ready for customers—dramatically reducing the wait time and improving the overall dining experience.

sequenceDiagram participant Client1 as Client 1 participant Client2 as Client 2 participant Client3 as Client 3 participant Pool as Connection Pool participant DB as PostgreSQL Database Note over Pool: Pool maintains pre-established connections Client1->>Pool: Request connection Pool->>Client1: Provide available connection Client1->>DB: Execute query DB->>Client1: Return results Client1->>Pool: Release connection Client2->>Pool: Request connection Pool->>Client2: Provide available connection Client3->>Pool: Request connection Pool->>Client3: Provide available connection Client2->>DB: Execute query DB->>Client2: Return results Client2->>Pool: Release connection Client3->>DB: Execute query DB->>Client3: Return results Client3->>Pool: Release connection

Why Connection Pooling Matters

Performance Improvement

Establishing a new database connection is expensive, involving TCP handshakes, authentication, and resource allocation. Connection pooling eliminates this overhead for each request, significantly improving response times.

Resource Management

Database servers have limits on the number of concurrent connections they can handle. Pooling ensures your application uses connections efficiently and prevents connection leaks.

Scalability

As your application scales to handle more traffic, connection pooling becomes essential to maintain performance and prevent your database from becoming a bottleneck.

Connection Reuse

Reusing connections avoids the overhead of creating and destroying connections repeatedly, which can be particularly important for applications with spiky traffic patterns.

Performance Impact: With vs. Without Pooling

Number of Concurrent Requests 10 50 100 200 500 Response Time (ms) 0 100 200 500 1000 With Pooling Without Pooling

As this simplified chart illustrates, applications without connection pooling experience rapidly deteriorating performance as concurrency increases, while pooled applications maintain more consistent response times.

Configuring the pg Pool

The pg library in Node.js provides a built-in connection pooling implementation through its Pool class. Let's explore how to configure and optimize it for different scenarios.

Basic Pool Configuration


const { Pool } = require('pg');
require('dotenv').config();

const pool = new Pool({
  // Connection parameters
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || 5432,
  database: process.env.DB_NAME || 'mydatabase',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || '',
  
  // Pool configuration
  max: 20, // Maximum number of clients in the pool
  idleTimeoutMillis: 30000, // How long a client is idle before being closed
  connectionTimeoutMillis: 2000, // How long to wait to establish a connection
  maxUses: 7500, // Number of times a client can be used before being recycled
  
  // SSL configuration (for production)
  // ssl: {
  //   rejectUnauthorized: false, // Only use this in trusted environments!
  //   ca: fs.readFileSync('/path/to/server-certificates/root.crt').toString(),
  // },
});

// Pool event handlers
pool.on('connect', (client) => {
  console.log('New client connected to PostgreSQL');
});

pool.on('acquire', (client) => {
  console.log('Client checked out from pool');
});

pool.on('remove', (client) => {
  console.log('Client removed from pool');
});

pool.on('error', (err, client) => {
  console.error('Unexpected error on idle client', err);
  // Optionally, you might want to restart the pool or exit the process
});

module.exports = {
  query: (text, params) => pool.query(text, params),
  getClient: async () => pool.connect(),
  pool // Export the pool itself for advanced usage
};
            

Key Configuration Parameters

Parameter Description Default Recommendation
max Maximum number of clients in the pool 10 Set based on your application needs and database capacity. A common formula is (number of CPU cores * 2) + 1
idleTimeoutMillis How long a client can remain idle before being closed 10000 (10s) 30000 (30s) is reasonable for most applications
connectionTimeoutMillis How long to wait for a connection before timing out 0 (no timeout) 2000-5000ms to fail fast in case of connection issues
maxUses Number of times a client can be used before being recycled Infinity 7500 helps prevent memory leaks from long-lived connections
keepAlive Whether to use TCP keepalive false true to help detect dead connections, especially useful with load balancers
allowExitOnIdle Allow Node.js to exit when pool is idle false Useful for scripts/CLI tools, but not for long-running servers
⚠️ Important Considerations
  • Database Limits: Ensure your PostgreSQL max_connections setting can accommodate your pooling configuration
  • Resource Consumption: Each connection consumes server resources, so don't set the pool size unnecessarily high
  • Connection Distribution: Remember that each Node.js instance/process will maintain its own connection pool

Sizing Your Connection Pool

Determining the optimal size for your connection pool is crucial for performance. Too few connections can create bottlenecks, while too many can waste resources and overload your database server.

Factors to Consider

graph TD A[PostgreSQL max_connections] --> B{Total Available Connections} C[Number of Application Instances] --> D{Connections Per Instance} B -- "Divide" --> D E[Peak Concurrent DB Operations Per Instance] --> F{Minimum Required} F -- "Compare" --> D G[Query Duration] --> F H[Overhead Buffer] -- "Add" --> F style B fill:#f9f9f9,stroke:#333,stroke-width:1px style D fill:#f9f9f9,stroke:#333,stroke-width:1px style F fill:#f9f9f9,stroke:#333,stroke-width:1px

Calculation Approach


// Pseudocode for pool size calculation
function calculateOptimalPoolSize() {
  // PostgreSQL server configuration
  const postgresMaxConnections = 100; // Check your postgresql.conf
  
  // Reserve connections for PostgreSQL maintenance and other applications
  const reservedConnections = 20;
  
  // Available connections for your application
  const availableConnections = postgresMaxConnections - reservedConnections;
  
  // Your application's deployment information
  const applicationInstances = 4; // Number of application servers/containers
  
  // Maximum connections per application instance
  const maxConnectionsPerInstance = Math.floor(availableConnections / applicationInstances);
  
  // Application requirements
  const avgQueryDurationMs = 100; // Average time a query takes
  const peakQueriesPerSecond = 200; // Peak query rate per instance
  const connectionOverheadFactor = 1.2; // Buffer for uneven distribution
  
  // Theoretical connections needed based on throughput
  const theoreticalConnectionsNeeded = Math.ceil(
    (peakQueriesPerSecond * (avgQueryDurationMs / 1000)) * connectionOverheadFactor
  );
  
  // Choose the smaller of the two to avoid overloading the database
  const optimalPoolSize = Math.min(theoreticalConnectionsNeeded, maxConnectionsPerInstance);
  
  return optimalPoolSize;
}
            

Practical Example: E-commerce Application

Scenario:
  • PostgreSQL configured with max_connections = 200
  • Application deployed across 5 instances
  • Average query duration: 50ms
  • Peak traffic: 500 requests/second per instance
  • Each request makes 3 database queries on average
Calculation:
  • Reserve 30 connections for PostgreSQL maintenance: 200 - 30 = 170 available
  • Max connections per instance: 170 / 5 = 34
  • Peak database operations per instance: 500 * 3 = 1500 queries/second
  • Theoretical connections needed: 1500 * (50/1000) * 1.2 = 90
  • Since 90 > 34, we're constrained by the database capacity
  • Optimal pool size: 34 connections per instance
Optimization Options:
  • Increase max_connections in PostgreSQL (if server resources allow)
  • Add more database replicas for read operations
  • Implement caching to reduce database load
  • Optimize queries to reduce duration
  • Use connection pooling at the database level (e.g., PgBouncer)

Pool Usage Patterns

Understanding different patterns for using the connection pool can help you write more efficient and reliable database code.

Pattern 1: Basic Query Execution

For simple queries where you don't need a dedicated client or transaction, use the pool's query method:


const db = require('./db');

async function getUserById(id) {
  const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return result.rows[0];
}
            

Pattern 2: Dedicated Client for Transactions

For transactions or when you need to maintain connection state, check out a dedicated client:


const db = require('./db');

async function transferFunds(fromAccountId, toAccountId, amount) {
  const client = await db.getClient();
  
  try {
    await client.query('BEGIN');
    
    // First operation
    const fromResult = await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1 RETURNING *',
      [amount, fromAccountId]
    );
    
    if (fromResult.rows.length === 0) {
      throw new Error('Insufficient funds or account not found');
    }
    
    // Second operation
    const toResult = await client.query(
      'UPDATE accounts SET balance = balance + $1 WHERE id = $2 RETURNING *',
      [amount, toAccountId]
    );
    
    if (toResult.rows.length === 0) {
      throw new Error('Destination account not found');
    }
    
    await client.query('COMMIT');
    
    return { success: true };
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    // ALWAYS release the client back to the pool
    client.release();
  }
}
            

Pattern 3: Batch Processing with Connection Reuse

For processing large datasets in batches while reusing the same connection:


const db = require('./db');

async function processLargeDataset(dataItems, batchSize = 100) {
  const client = await db.getClient();
  
  try {
    const results = [];
    
    // Process data in batches
    for (let i = 0; i < dataItems.length; i += batchSize) {
      const batch = dataItems.slice(i, i + batchSize);
      
      // Start a transaction for each batch
      await client.query('BEGIN');
      
      try {
        for (const item of batch) {
          const result = await client.query(
            'INSERT INTO processed_items (data, processed_at) VALUES ($1, NOW()) RETURNING id',
            [item]
          );
          results.push(result.rows[0].id);
        }
        
        await client.query('COMMIT');
        console.log(`Processed batch ${i / batchSize + 1}: ${batch.length} items`);
      } catch (err) {
        await client.query('ROLLBACK');
        console.error(`Error processing batch ${i / batchSize + 1}:`, err);
        throw err;
      }
    }
    
    return results;
  } finally {
    client.release();
  }
}
            

Pattern 4: Connection Pool Queuing

Understanding how the pool queues connection requests and handling queue timeouts:


const { Pool } = require('pg');

// Configure a pool with a connection timeout
const pool = new Pool({
  // Connection parameters...
  connectionTimeoutMillis: 3000, // 3 seconds
  max: 10
});

async function executeQuery(query, params) {
  try {
    return await pool.query(query, params);
  } catch (err) {
    // Check for connection timeout errors
    if (err.message === 'timeout exceeded when trying to connect') {
      console.error('Connection pool timeout - database may be overloaded');
      // Implement retry logic, circuit breaking, or graceful degradation
      throw new Error('Database temporarily unavailable');
    }
    throw err;
  }
}

// Connection queue metrics
function getPoolStatus() {
  return {
    totalCount: pool.totalCount,
    idleCount: pool.idleCount,
    waitingCount: pool.waitingCount,
    maxSize: pool.options.max
  };
}
            
Connection Usage Best Practices
  • Always Release: Always release clients back to the pool, using try/finally blocks
  • Avoid Leaks: Track all client acquisitions to prevent connection leaks
  • Short Operations: Keep operations on checked-out clients as short as possible
  • Error Handling: Handle connection errors and implement retries when appropriate
  • Avoid Nesting: Don't acquire new connections within callbacks that already have connections

Beyond Node.js: PgBouncer for Advanced Connection Pooling

While the pg library's built-in pooling is sufficient for many applications, larger systems can benefit from a dedicated connection pooling solution like PgBouncer.

graph TD A[Node.js Instances] -->|Connect To| B[PgBouncer] B -->|Manages Connections To| C[PostgreSQL] style A fill:#f9f9f9,stroke:#333,stroke-width:1px style B fill:#4CAF50,stroke:#333,stroke-width:1px style C fill:#f9f9f9,stroke:#333,stroke-width:1px

What is PgBouncer?

PgBouncer is a lightweight connection pooler for PostgreSQL that sits between your application and the database server. It maintains a pool of connections to PostgreSQL and proxies client connections through these pooled connections.

Advantages of PgBouncer

PgBouncer Pooling Modes

Mode Description Use Case
Session Pooling Client connection is assigned to a PostgreSQL server connection for the duration of the client connection Applications that require session state (temp tables, prepared statements)
Transaction Pooling PostgreSQL connection is assigned only for the duration of a transaction Most web applications, RESTful APIs
Statement Pooling PostgreSQL connection is assigned only for the duration of a statement Applications with many short-lived queries

Basic PgBouncer Setup


# pgbouncer.ini
[databases]
* = host=127.0.0.1 port=5432 dbname=mydatabase

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
            

Integrating PgBouncer with Node.js


// db.js with PgBouncer configuration
const { Pool } = require('pg');
require('dotenv').config();

const pool = new Pool({
  // Connect to PgBouncer instead of directly to PostgreSQL
  host: process.env.PGBOUNCER_HOST || 'localhost',
  port: process.env.PGBOUNCER_PORT || 6432, // PgBouncer port
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  
  // Smaller pool size since PgBouncer handles the real pooling
  max: 10,
  
  // May need to disable prepared statements in transaction pooling mode
  // See: https://github.com/brianc/node-postgres/issues/1206
  statement_timeout: 5000 // 5s timeout for statements
});

module.exports = {
  query: (text, params) => pool.query(text, params),
  getClient: async () => pool.connect()
};
            
⚠️ PgBouncer Considerations
  • Prepared Statements: Limited support in transaction and statement pooling modes
  • LISTEN/NOTIFY: Works only in session pooling mode
  • SET/RESET: Session variables persist only in session pooling mode
  • Security: Proper authentication and network security configuration is essential

Monitoring and Troubleshooting Connection Pools

Effective monitoring is crucial for ensuring your connection pool is properly sized and functioning correctly.

Key Metrics to Monitor

Implementing Pool Monitoring in Node.js


const { Pool } = require('pg');
const EventEmitter = require('events');

class MonitoredPool extends EventEmitter {
  constructor(config) {
    super();
    this.pool = new Pool(config);
    this.metrics = {
      totalRequests: 0,
      activeConnections: 0,
      waitingRequests: 0,
      connectionTime: [],
      queryTime: [],
      errors: 0,
      lastError: null
    };
    
    this._setupPoolListeners();
  }
  
  _setupPoolListeners() {
    // Track total connections
    this.pool.on('connect', () => {
      this.metrics.activeConnections++;
      this.emit('metrics', this.metrics);
    });
    
    this.pool.on('remove', () => {
      this.metrics.activeConnections--;
      this.emit('metrics', this.metrics);
    });
    
    this.pool.on('error', (err) => {
      this.metrics.errors++;
      this.metrics.lastError = {
        message: err.message,
        timestamp: new Date()
      };
      this.emit('poolError', err);
      this.emit('metrics', this.metrics);
    });
  }
  
  async query(text, params) {
    const start = Date.now();
    this.metrics.totalRequests++;
    this.metrics.waitingRequests++;
    
    try {
      const result = await this.pool.query(text, params);
      
      const duration = Date.now() - start;
      this.metrics.queryTime.push(duration);
      if (this.metrics.queryTime.length > 100) {
        this.metrics.queryTime.shift(); // Keep only last 100 queries
      }
      
      return result;
    } catch (err) {
      this.metrics.errors++;
      this.metrics.lastError = {
        message: err.message,
        query: text,
        timestamp: new Date()
      };
      throw err;
    } finally {
      this.metrics.waitingRequests--;
      this.emit('metrics', this.metrics);
    }
  }
  
  async getClient() {
    const start = Date.now();
    this.metrics.totalRequests++;
    this.metrics.waitingRequests++;
    
    try {
      const client = await this.pool.connect();
      const connectionTime = Date.now() - start;
      
      this.metrics.connectionTime.push(connectionTime);
      if (this.metrics.connectionTime.length > 100) {
        this.metrics.connectionTime.shift();
      }
      
      // Wrap the release method to track when connections are released
      const originalRelease = client.release;
      client.release = () => {
        originalRelease.apply(client);
        this.emit('metrics', this.metrics);
      };
      
      return client;
    } catch (err) {
      this.metrics.errors++;
      this.metrics.lastError = {
        message: err.message,
        timestamp: new Date()
      };
      throw err;
    } finally {
      this.metrics.waitingRequests--;
      this.emit('metrics', this.metrics);
    }
  }
  
  getMetrics() {
    return {
      ...this.metrics,
      avgConnectionTime: this._calculateAverage(this.metrics.connectionTime),
      avgQueryTime: this._calculateAverage(this.metrics.queryTime),
      poolStats: {
        totalCount: this.pool.totalCount,
        idleCount: this.pool.idleCount,
        waitingCount: this.pool.waitingCount
      }
    };
  }
  
  _calculateAverage(array) {
    if (array.length === 0) return 0;
    return array.reduce((a, b) => a + b, 0) / array.length;
  }
  
  end() {
    return this.pool.end();
  }
}

module.exports = MonitoredPool;
            

Using the Monitored Pool


// db.js
const MonitoredPool = require('./MonitoredPool');
require('dotenv').config();

const pool = new MonitoredPool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20
});

// Log metrics periodically
setInterval(() => {
  const metrics = pool.getMetrics();
  console.log('DB Pool Metrics:', {
    activeConnections: metrics.activeConnections,
    waitingRequests: metrics.waitingRequests,
    avgConnectionTime: `${metrics.avgConnectionTime.toFixed(2)}ms`,
    avgQueryTime: `${metrics.avgQueryTime.toFixed(2)}ms`,
    errors: metrics.errors,
    idleCount: metrics.poolStats.idleCount,
    totalCount: metrics.poolStats.totalCount
  });
}, 60000); // Log every minute

// Expose metrics endpoint for monitoring systems
app.get('/metrics/db-pool', (req, res) => {
  res.json(pool.getMetrics());
});

// Emit events when metrics change significantly
pool.on('metrics', (metrics) => {
  // Check for warning conditions
  if (metrics.waitingRequests > 5) {
    console.warn(`High connection wait queue: ${metrics.waitingRequests} requests waiting`);
  }
  
  if (metrics.activeConnections >= 20) { // At max connections
    console.warn('Pool reached maximum size, consider increasing pool size');
  }
});

module.exports = {
  query: (text, params) => pool.query(text, params),
  getClient: async () => pool.getClient(),
  getMetrics: () => pool.getMetrics()
};
            

Common Connection Pool Issues and Solutions

Problem Symptoms Solution
Connection Leaks Pool reaches max size but idleCount stays low
  • Ensure all client.release() calls in finally blocks
  • Use maxUses parameter to recycle connections
  • Implement connection timeout monitoring
Long-Running Queries High activeConnections, low throughput
  • Optimize queries
  • Implement query timeout
  • Use separate pools for long and short queries
Pool Exhaustion High waitingCount, connection timeouts
  • Increase pool size (if database can handle it)
  • Implement a circuit breaker to fail fast
  • Add caching to reduce database load
Database Server Overload Slow queries, connection failures
  • Add read replicas and split read/write operations
  • Implement backpressure mechanisms
  • Consider using PgBouncer to limit connections

Advanced Connection Pool Techniques

For high-performance applications, consider these advanced patterns and techniques.

Multiple Pools for Different Workloads


// db.js with multiple pools
const { Pool } = require('pg');
require('dotenv').config();

// Configuration factory
const createPoolConfig = (maxConnections, timeout) => ({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: maxConnections,
  idleTimeoutMillis: timeout,
  statement_timeout: timeout
});

// Pool for fast, transactional queries
const transactionPool = new Pool(createPoolConfig(15, 3000));

// Pool for longer analytical queries
const analyticsPool = new Pool(createPoolConfig(5, 30000));

// Pool for read-only queries (could connect to a replica)
const readPool = new Pool({
  ...createPoolConfig(20, 5000),
  host: process.env.DB_READ_REPLICA_HOST || process.env.DB_HOST
});

module.exports = {
  // Transaction operations (inserts, updates, etc.)
  transaction: {
    query: (text, params) => transactionPool.query(text, params),
    getClient: async () => transactionPool.connect()
  },
  
  // Analytical operations (reports, aggregations)
  analytics: {
    query: (text, params) => analyticsPool.query(text, params),
    getClient: async () => analyticsPool.connect()
  },
  
  // Read operations (selects)
  read: {
    query: (text, params) => readPool.query(text, params),
    getClient: async () => readPool.connect()
  },
  
  // Release all pools (for cleanup)
  end: async () => {
    await Promise.all([
      transactionPool.end(),
      analyticsPool.end(),
      readPool.end()
    ]);
  }
};
            

Implementing a Circuit Breaker


// circuitBreaker.js
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
    
    this.failures = 0;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.lastFailureTime = null;
    this.successThreshold = options.successThreshold || 2;
    this.successCount = 0;
  }
  
  async execute(operation) {
    if (this.state === 'OPEN') {
      // Check if it's time to try again
      const now = Date.now();
      if (this.lastFailureTime && (now - this.lastFailureTime) > this.resetTimeout) {
        this.state = 'HALF_OPEN';
        console.log('Circuit breaker state changed to HALF_OPEN');
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }
    
    try {
      const result = await operation();
      
      // If we're in HALF_OPEN, count successes
      if (this.state === 'HALF_OPEN') {
        this.successCount++;
        if (this.successCount >= this.successThreshold) {
          this.reset();
        }
      }
      
      return result;
    } catch (err) {
      this.recordFailure();
      throw err;
    }
  }
  
  recordFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    
    if (this.state === 'CLOSED' && this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
      console.log('Circuit breaker state changed to OPEN');
    }
    
    if (this.state === 'HALF_OPEN') {
      this.state = 'OPEN';
      console.log('Circuit breaker state changed to OPEN from HALF_OPEN due to failure');
    }
  }
  
  reset() {
    this.failures = 0;
    this.state = 'CLOSED';
    this.successCount = 0;
    console.log('Circuit breaker reset to CLOSED state');
  }
  
  getState() {
    return {
      state: this.state,
      failures: this.failures,
      lastFailureTime: this.lastFailureTime,
      successCount: this.successCount
    };
  }
}

// Using the circuit breaker with a connection pool
const db = require('./db');
const circuitBreaker = new CircuitBreaker({
  failureThreshold: 3,
  resetTimeout: 10000 // 10 seconds
});

async function getUserWithCircuitBreaker(id) {
  try {
    return await circuitBreaker.execute(async () => {
      const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
      return result.rows[0];
    });
  } catch (err) {
    if (err.message === 'Circuit breaker is OPEN') {
      // Fallback mechanism (e.g., serve from cache, show default data)
      console.log('Serving fallback response due to open circuit');
      return { id, name: 'Unknown User', fallback: true };
    }
    throw err;
  }
}
            

Connection Pool Backpressure


// Implementing backpressure for a web API
const express = require('express');
const db = require('./db');
const app = express();

// Simple backpressure middleware
function databaseBackpressure(req, res, next) {
  const metrics = db.getMetrics();
  
  // If pool is under heavy load, slow down incoming requests
  if (metrics.waitingRequests > 5) {
    const delay = 100 * metrics.waitingRequests; // Linear delay based on queue length
    
    // Add response headers for transparency
    res.set('X-Backpressure-Applied', 'true');
    res.set('X-Backpressure-Delay', delay.toString());
    
    setTimeout(() => {
      next();
    }, delay);
  } else {
    next();
  }
}

// Apply to all routes that use the database
app.use('/api/*', databaseBackpressure);

// Alternative: reject requests when overloaded
function criticalBackpressure(req, res, next) {
  const metrics = db.getMetrics();
  
  if (metrics.waitingRequests > 20) {
    // Server is critically overloaded
    return res.status(503).json({
      error: 'Service temporarily overloaded',
      retryAfter: '5' // Seconds
    });
  }
  
  next();
}

app.use('/api/critical/*', criticalBackpressure);
            

Dynamic Pool Sizing


// Adaptive pool sizing based on load
class AdaptivePool {
  constructor(config) {
    this.config = config;
    this.minSize = config.min || 5;
    this.maxSize = config.max || 20;
    this.currentSize = this.minSize;
    this.pool = new Pool({
      ...config,
      max: this.currentSize
    });
    
    this.metricsInterval = setInterval(() => this.adjustPoolSize(), 10000);
  }
  
  async adjustPoolSize() {
    const metrics = {
      totalCount: this.pool.totalCount,
      idleCount: this.pool.idleCount,
      waitingCount: this.pool.waitingCount
    };
    
    const utilizationRatio = (metrics.totalCount - metrics.idleCount) / metrics.totalCount;
    
    if (utilizationRatio > 0.7 && metrics.waitingCount > 0) {
      // High utilization with waiting clients - scale up
      const newSize = Math.min(this.currentSize + 2, this.maxSize);
      
      if (newSize > this.currentSize) {
        await this.resizePool(newSize);
        console.log(`Scaled up pool from ${this.currentSize} to ${newSize}`);
      }
    } else if (utilizationRatio < 0.3 && metrics.waitingCount === 0) {
      // Low utilization with no waiting clients - scale down
      const newSize = Math.max(this.currentSize - 1, this.minSize);
      
      if (newSize < this.currentSize) {
        await this.resizePool(newSize);
        console.log(`Scaled down pool from ${this.currentSize} to ${newSize}`);
      }
    }
  }
  
  async resizePool(newSize) {
    // Create a new pool with the desired size
    const newPool = new Pool({
      ...this.config,
      max: newSize
    });
    
    // Keep a reference to the old pool
    const oldPool = this.pool;
    
    // Switch to the new pool
    this.pool = newPool;
    this.currentSize = newSize;
    
    // Give existing operations time to complete before ending the old pool
    setTimeout(() => {
      oldPool.end().catch(err => {
        console.error('Error ending old pool:', err);
      });
    }, 30000); // 30 second grace period
  }
  
  // Delegate methods to the current pool
  query(text, params) {
    return this.pool.query(text, params);
  }
  
  connect() {
    return this.pool.connect();
  }
  
  end() {
    clearInterval(this.metricsInterval);
    return this.pool.end();
  }
}
            

Practical Activities

Activity 1: Pool Benchmarking

Create a benchmarking tool to compare different pool configurations:

  1. Set up a PostgreSQL database with a test table containing at least 100,000 rows
  2. Create a Node.js script that executes a series of queries with different pool sizes (5, 10, 20, 50, 100)
  3. Measure throughput (queries per second) and response time for:
    • Simple point queries (fetch by id)
    • Range queries (fetch with condition)
    • Insert operations
  4. Generate charts to visualize how performance changes with pool size
  5. Identify the optimal pool size for your specific hardware

Activity 2: Connection Pool Monitoring Dashboard

Implement a real-time monitoring dashboard for your connection pool:

  1. Create a MonitoredPool class that extends the pg Pool with metrics collection
  2. Implement an Express API endpoint that exposes pool metrics
  3. Build a simple web dashboard using HTML, CSS, and JavaScript to visualize:
    • Active, idle, and waiting connections over time
    • Average query and connection acquisition times
    • Error rates and types
  4. Add alerts for critical conditions (pool exhaustion, high wait times)
  5. Test the dashboard under various load scenarios

Activity 3: Advanced Connection Management

Implement a robust connection management system for a high-traffic application:

  1. Set up multiple connection pools for different types of operations:
    • Transactional (writes, ACID operations)
    • Read-only (queries that don't modify data)
    • Reporting (long-running analytical queries)
  2. Implement a circuit breaker to handle database outages
  3. Add a backpressure mechanism to slow down requests when the database is overloaded
  4. Create a load testing script to simulate traffic spikes
  5. Monitor and tune your system to handle the load effectively

Further Reading and Resources

Coming Up: MongoDB Basics

In our next session, we'll shift gears to explore MongoDB, a popular NoSQL database. We'll learn about its document-oriented data model, core concepts, and how to integrate it with Node.js applications.