Skip to content

How to Configure Connection Pool Settings for Node.js in Production

I deployed our Node.js microservices to production with 20 replicas, and everything seemed fine during testing. Then a traffic spike hit, and suddenly the entire system ground to a halt. The logs showed a familiar error:

Error: sorry, too many clients already

PostgreSQL was rejecting connections. But we had configured everything correctly, or so I thought. The problem wasn’t our database settings—it was our connection pool math.

The Problem: Default Pool Settings Don’t Scale

When I investigated, I found the culprit. We were using the default connection pool size for pg (node-postgres), which is 10 connections per instance. With 20 replicas running, that meant:

  • 20 replicas x 10 connections = 200 database connections
  • PostgreSQL max_connections default: 100

We were trying to open twice as many connections as the database allowed. The math was simple, but I had completely overlooked it.

This isn’t just a PostgreSQL problem. Redis, HTTP clients—anything with a connection pool has defaults that work fine for development but fall apart in production with multiple instances.

Understanding Connection Pool Sizing

The key insight is that each Node.js instance maintains its own connection pool. If you have:

  • N replicas running your Node.js application
  • M max_connections on your database
  • Each replica opening P pool connections

Then you must ensure: N x P < M

Let me walk through how I fixed this.

Step 1: Calculate the Right Pool Size

The formula is straightforward:

pool_size_per_instance = floor(max_connections / number_of_instances) - safety_buffer

The safety buffer reserves connections for admin tasks, migrations, and emergency access.

With 20 replicas and PostgreSQL max_connections = 200:

pool_size = floor(200 / 20) - 5 = 5 connections per instance

Step 2: Configure PostgreSQL Pool with Calculated Values

I created a configuration module that calculates pool size based on environment variables:

config/database.js
import { Pool } from 'pg'
// Calculate based on your infrastructure
const REPLICA_COUNT = parseInt(process.env.REPLICA_COUNT || '1', 10)
const MAX_DB_CONNECTIONS = parseInt(process.env.MAX_DB_CONNECTIONS || '100', 10)
const SAFETY_BUFFER = 5 // Reserve connections for admin/maintenance
const poolSizePerReplica = Math.floor(MAX_DB_CONNECTIONS / REPLICA_COUNT) - SAFETY_BUFFER
const pool = new Pool({
// Connection details from environment variables
host: process.env.PGHOST || 'localhost',
port: parseInt(process.env.PGPORT || '5432', 10),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
// Pool sizing - THE CRITICAL SETTINGS
max: Math.max(poolSizePerReplica, 2), // Minimum 2 connections per instance
min: Math.max(Math.floor(poolSizePerReplica * 0.3), 1), // Keep 30% of max alive
// Timeouts - Production-appropriate values
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 5000, // Fail fast if can't connect (5s)
maxLifetimeSeconds: 1800, // Rotate connections every 30 minutes
// SSL for production
ssl: process.env.NODE_ENV === 'production' ? {
rejectUnauthorized: true
} : false
})
// CRITICAL: Handle pool errors to prevent crashes
pool.on('error', (err, client) => {
console.error('Unexpected error on idle PostgreSQL client', err)
// Consider alerting/monitoring integration here
})
// Monitoring helper
export function getPoolStats() {
return {
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
maxConfigured: pool.options.max
}
}
export default pool

Why These Settings Matter

  • max: The upper limit of connections in the pool. This is the critical setting.
  • min: Maintains a baseline of open connections for faster response times.
  • idleTimeoutMillis: Closes connections that haven’t been used, freeing resources.
  • connectionTimeoutMillis: Fails fast rather than hanging when the database is unreachable.
  • maxLifetimeSeconds: Rotates connections to prevent long-lived connection issues.

Step 3: Set Environment Variables

The configuration reads from environment variables, making it easy to adjust per environment:

.env.production
PGHOST=your-postgres-host
PGPORT=5432
PGDATABASE=production_db
PGUSER=app_user
PGPASSWORD=your-secure-password
# Infrastructure awareness - CRITICAL
REPLICA_COUNT=20
MAX_DB_CONNECTIONS=200
# Environment
NODE_ENV=production

The REPLICA_COUNT and MAX_DB_CONNECTIONS variables are essential. Without them, you can’t calculate the correct pool size.

Step 4: Configure Redis for Production

Redis handles connections differently than PostgreSQL, but similar principles apply. I use ioredis with explicit configuration:

config/redis.js
import Redis from 'ioredis'
// Redis connection configuration for production
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0', 10),
// Connection naming for monitoring
connectionName: `${process.env.SERVICE_NAME || 'app'}-${process.env.HOSTNAME || 'local'}`,
// Connection management
connectTimeout: 5000, // 5 seconds to establish connection
commandTimeout: 3000, // 3 seconds for commands to complete
keepAlive: 30000, // TCP keepalive every 30s
// Retry strategy - exponential backoff
retryStrategy: (times) => {
if (times > 10) {
console.error('Redis connection failed after 10 retries')
return null // Stop retrying
}
const delay = Math.min(times * 100, 3000) // Max 3s delay
console.warn(`Redis reconnecting in ${delay}ms (attempt ${times})`)
return delay
},
// Offline queue management
enableOfflineQueue: true,
maxRetriesPerRequest: 3, // Fail fast after 3 retries
// Performance optimization
enableAutoPipelining: true, // Batch commands automatically
enableReadyCheck: true, // Wait for Redis to be ready
// Reconnection behavior
autoResendUnfulfilledCommands: true,
autoResubscribe: true
}
const redis = new Redis(redisConfig)
// Event handlers for monitoring
redis.on('connect', () => {
console.log('Redis connecting...')
})
redis.on('ready', () => {
console.log('Redis ready')
})
redis.on('error', (err) => {
console.error('Redis error:', err)
})
redis.on('close', () => {
console.warn('Redis connection closed')
})
redis.on('reconnecting', () => {
console.log('Redis reconnecting...')
})
export default redis

Key Redis Configuration Choices

  • connectTimeout: Don’t wait forever for a connection
  • commandTimeout: Commands should complete quickly
  • retryStrategy: Exponential backoff with a limit prevents infinite retry loops
  • enableAutoPipelining: Batches commands for better performance

Step 5: Add Monitoring for Pool Health

I created a middleware to track pool metrics:

middleware/poolMetrics.js
export function poolMetricsMiddleware(pgPool) {
return (req, res, next) => {
const stats = {
pg: {
total: pgPool.totalCount,
idle: pgPool.idleCount,
waiting: pgPool.waitingCount
}
}
// Add to request for logging
req.poolStats = stats
// Alert if pool is stressed
if (stats.pg.waiting > 5) {
console.warn('PostgreSQL pool under pressure:', stats.pg)
}
next()
}
}

This helped me identify when the pool was running low on available connections, before it became a critical issue.

Step 6: Implement Graceful Shutdown

Properly closing connections during deployment prevents connection leaks:

server.js
import pgPool from './config/database.js'
import redis from './config/redis.js'
async function gracefulShutdown(signal) {
console.log(`Received ${signal}, closing connections...`)
try {
await pgPool.end()
console.log('PostgreSQL pool closed')
await redis.quit()
console.log('Redis connection closed')
process.exit(0)
} catch (err) {
console.error('Error during shutdown:', err)
process.exit(1)
}
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))

Bonus: Redis Connection Pool for High Throughput

For applications with high Redis throughput, a connection pool can distribute load:

config/redis-pool.js
import Redis from 'ioredis'
class RedisPool {
constructor(options = {}) {
this.poolSize = options.poolSize || 5
this.connections = []
this.currentIndex = 0
this.config = options.config || {}
// Initialize pool
for (let i = 0; i < this.poolSize; i++) {
const conn = new Redis({
...this.config,
connectionName: `${this.config.connectionName}-pool-${i}`
})
this.connections.push(conn)
}
}
// Round-robin connection selection
getConnection() {
const conn = this.connections[this.currentIndex]
this.currentIndex = (this.currentIndex + 1) % this.poolSize
return conn
}
// Proxy common commands
async get(key) {
return this.getConnection().get(key)
}
async set(key, value, ...args) {
return this.getConnection().set(key, value, ...args)
}
async del(key) {
return this.getConnection().del(key)
}
// Cleanup
async quit() {
await Promise.all(this.connections.map(conn => conn.quit()))
}
}
// Usage
const redisPool = new RedisPool({
poolSize: 5,
config: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
connectionName: 'myapp'
}
})
export default redisPool

This distributes commands across multiple Redis connections, reducing contention.

The Complete Production Setup

Here’s a consolidated module for both databases:

config/connections.js
import { Pool } from 'pg'
import Redis from 'ioredis'
// PostgreSQL configuration
export function createPgPool() {
const replicaCount = parseInt(process.env.REPLICA_COUNT || '1', 10)
const maxDbConnections = parseInt(process.env.MAX_DB_CONNECTIONS || '100', 10)
const poolSize = Math.max(Math.floor(maxDbConnections / replicaCount) - 5, 2)
const pool = new Pool({
max: poolSize,
min: Math.max(Math.floor(poolSize * 0.3), 1),
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
maxLifetimeSeconds: 1800,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false
})
pool.on('error', (err) => {
console.error('PostgreSQL pool error:', err)
})
return pool
}
// Redis configuration
export function createRedisClient() {
return new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
connectionName: process.env.SERVICE_NAME,
connectTimeout: 5000,
commandTimeout: 3000,
keepAlive: 30000,
retryStrategy: (times) => times > 10 ? null : Math.min(times * 100, 3000),
maxRetriesPerRequest: 3,
enableAutoPipelining: true
})
}
// Health check utility
export async function checkDatabaseHealth(pgPool, redis) {
const health = {
postgresql: 'unhealthy',
redis: 'unhealthy',
timestamp: new Date().toISOString()
}
try {
await pgPool.query('SELECT 1')
health.postgresql = 'healthy'
} catch (err) {
console.error('PostgreSQL health check failed:', err)
}
try {
await redis.ping()
health.redis = 'healthy'
} catch (err) {
console.error('Redis health check failed:', err)
}
return health
}

Production Checklist

Before deploying to production, verify:

  • Calculate pool size based on replica count
  • Set appropriate timeouts (connection, idle, command)
  • Configure SSL for all connections
  • Implement error handlers for both pools
  • Add monitoring/metrics for pool health
  • Test connection limits under load
  • Implement graceful shutdown
  • Document pool settings for your infrastructure

Key Takeaways

  1. Never use defaults in production - Default pool sizes (10 for pg) don’t account for multiple replicas
  2. Do the pool math - Calculate pool_size = max_connections / replica_count - buffer
  3. Set appropriate timeouts - Fail fast, don’t let connections hang
  4. Monitor pool health - Track total, idle, and waiting connections
  5. Handle errors gracefully - Pool errors shouldn’t crash your app
  6. Plan for scaling - As you add replicas, recalculate pool sizes

The production outage taught me an important lesson: connection pool configuration isn’t a “set and forget” task. It requires understanding your infrastructure, calculating based on actual constraints, and monitoring to catch issues before they become outages.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments