Module P-10·18 min read

TCP connection overhead, ioredis connection pool sizing, reconnection strategies with exponential backoff, command timeout configuration, lazy connect vs eager connect, and health check patterns for production clients.

P-10 — Connection Pooling and Client Configuration

Who this module is for: You create a Redis client with new Redis() and call commands on it. It works. But under production load you see connection refused errors, command timeouts, or Redis reporting hundreds of connected clients. This module covers how Redis connections work at the TCP level, how ioredis manages connections, and how to configure your client for production reliability.


The Cost of a TCP Connection

Every Redis client maintains at least one persistent TCP connection to the Redis server. Establishing a TCP connection requires a three-way handshake (SYN → SYN-ACK → ACK) plus any TLS handshake — roughly 1–3 RTTs before the first command can be sent.

Redis itself can handle tens of thousands of concurrent connections, but each connection consumes:

  • ~20KB of kernel socket buffer (send + receive)
  • A slot in Redis's client list (memory for the client struct, output buffer)
  • A file descriptor on both the client and server side

For a Node.js application, one Redis client holds one persistent TCP connection that is multiplexed — all commands share the connection via the RESP protocol. This is different from PostgreSQL's connection model where each database session is a separate stateful connection requiring explicit pooling.


ioredis Connection Model

ioredis maintains a single persistent TCP connection per Redis instance. Commands are sent over this connection and responses are parsed in order. Pipelining (auto-pipelining) batches commands issued in the same event loop tick.

typescript
import Redis from 'ioredis'; // Single connection — suitable for most applications const redis = new Redis({ host: 'redis-primary.internal', port: 6379, password: process.env.REDIS_PASSWORD, db: 0, });

When One Connection is Not Enough

A single connection becomes a bottleneck when:

  • You have many concurrent await redis.command() calls that cannot be batched
  • Some commands block the connection (BLPOP, SUBSCRIBE)
  • You need Pub/Sub (subscriber mode) alongside normal commands

For these cases, create multiple Redis instances or use a cluster/sentinel client.


Key Configuration Options

typescript
const redis = new Redis({ host: 'redis.internal', port: 6379, password: process.env.REDIS_PASSWORD, db: 0, // Connection lifecycle connectTimeout: 10000, // ms to wait for initial connection (default: 10000) commandTimeout: 5000, // ms to wait for a command response (default: undefined = no timeout) keepAlive: 30000, // TCP keepalive in ms (0 = disabled); prevents silent drops // Reconnection maxRetriesPerRequest: 3, // retries per command before failing (default: 20 — often too high) retryStrategy: (times) => { // ms to wait before reconnect attempt if (times > 10) return null; // give up after 10 attempts return Math.min(times * 100, 3000); // exponential backoff, cap at 3s }, // Lazy connect (don't connect until first command) lazyConnect: true, // default: false (connects immediately on construction) // TLS tls: process.env.REDIS_TLS === 'true' ? {} : undefined, // Enable auto-pipelining (batch concurrent commands) enableAutoPipelining: true, // default: false in ioredis; enabled in some wrappers });

connectTimeout vs commandTimeout

  • connectTimeout — how long to wait for the initial TCP + auth handshake. If Redis is briefly unavailable at startup, this controls how long you wait before failing.
  • commandTimeout — how long to wait for a command response after sending it. This is the most important setting to prevent hung requests. Without it, a command can wait forever if the connection drops mid-flight.

Always set commandTimeout. A reasonable value is 2–5× your P99 Redis latency. For a Redis instance with P99 < 5ms, set commandTimeout: 500 (500ms). This allows for occasional latency spikes without hanging your application.

retryStrategy

The retry strategy function receives the number of previous retry attempts and returns the milliseconds to wait before the next attempt (or null to stop retrying).

typescript
retryStrategy: (times) => { if (times > 10) { console.error('Redis connection lost after 10 retries'); return null; // stop retrying — triggers 'end' event } // Exponential backoff with jitter const base = Math.min(times * 200, 2000); return base + Math.random() * 200; },

maxRetriesPerRequest: 3 — limits how many times a command is retried on connection error. The default of 20 means a command issued during a Redis outage can block for up to 20 reconnection cycles before failing. For most applications, 2–3 retries is more appropriate.


Connection Events

typescript
redis.on('connect', () => { console.log('Redis connected'); }); redis.on('ready', () => { console.log('Redis ready to serve commands'); // 'connect' fires when TCP is established; 'ready' fires after AUTH and SELECT }); redis.on('error', (err) => { console.error('Redis error:', err.message); // Do NOT throw here — ioredis handles reconnection automatically // This handler is for logging and alerting only }); redis.on('close', () => { console.warn('Redis connection closed'); }); redis.on('reconnecting', (delay: number) => { console.log(`Reconnecting in ${delay}ms`); }); redis.on('end', () => { console.error('Redis connection permanently closed (retries exhausted)'); // Alert your monitoring system — Redis is unreachable });

Always listen for error events. An unhandled error event on an EventEmitter crashes the Node.js process. Even if you just log and continue, register the handler.


Multiple Connections for Specific Use Cases

Pub/Sub Requires a Dedicated Connection

typescript
const redis = new Redis(); // for normal commands const subscriber = new Redis(); // dedicated subscriber connection subscriber.subscribe('notifications:global'); subscriber.on('message', (channel, message) => { /* ... */ }); // Normal commands still work on redis await redis.set('key', 'value');

A subscribed connection cannot issue non-Pub/Sub commands. Always use a separate Redis instance for subscribers.

Blocking Commands

BLPOP, BRPOP, BLMOVE, XREAD BLOCK — these block the connection until data is available. If you use them on your main Redis connection, no other commands can be sent until the block resolves.

typescript
const blockingRedis = new Redis(); // dedicated for blocking operations // This blocks for up to 5 seconds const result = await blockingRedis.blpop('tasks:queue', 5); // Your main redis connection is unaffected

Health Checks and Circuit Breaking

In production, Redis may become temporarily unavailable — brief network interruptions, rolling restarts, failovers. Your application should degrade gracefully rather than hanging.

Periodic Health Check

typescript
async function checkRedisHealth(): Promise<boolean> { try { const result = await redis.ping(); return result === 'PONG'; } catch { return false; } } // Run health check every 30 seconds setInterval(async () => { const healthy = await checkRedisHealth(); if (!healthy) { metrics.increment('redis.health_check.failed'); alert.trigger('Redis health check failed'); } }, 30000);

Graceful Degradation

Design cache reads to degrade gracefully when Redis is unavailable:

typescript
async function getCachedUser(userId: string) { try { const cached = await redis.get(`user:${userId}`); if (cached) return JSON.parse(cached); } catch (err) { // Redis unavailable — log and fall through to database logger.warn('Redis get failed, falling back to DB', { userId, err: err.message }); metrics.increment('redis.miss.fallback'); } // Database fallback return db.query('SELECT * FROM users WHERE id = $1', [userId]); }

Cache writes should also be non-blocking on failure:

typescript
async function setCachedUser(userId: string, data: User) { try { await redis.set(`user:${userId}`, JSON.stringify(data), 'EX', 300); } catch (err) { // Cache write failure is non-fatal — data is in the database logger.warn('Redis set failed', { userId, err: err.message }); } }

Sentinel and Cluster Clients

For production deployments with Sentinel or Cluster, ioredis provides dedicated client classes:

Sentinel Client

typescript
const redis = new Redis({ sentinels: [ { host: 'sentinel-1.internal', port: 26379 }, { host: 'sentinel-2.internal', port: 26379 }, { host: 'sentinel-3.internal', port: 26379 }, ], name: 'mymaster', // master set name configured in sentinel.conf password: process.env.REDIS_PASSWORD, sentinelPassword: process.env.SENTINEL_PASSWORD, // if sentinels require auth });

The Sentinel client queries the sentinels to discover the current primary address and reconnects automatically on failover.

Cluster Client

typescript
import { Cluster } from 'ioredis'; const redis = new Cluster([ { host: 'redis-node-1.internal', port: 6379 }, { host: 'redis-node-2.internal', port: 6379 }, { host: 'redis-node-3.internal', port: 6379 }, ], { redisOptions: { password: process.env.REDIS_PASSWORD, commandTimeout: 500, }, // Number of connections per node for read scaling scaleReads: 'slave', // route read commands to replicas });

The Cluster client handles MOVED and ASK redirections automatically.


Monitoring Connections

INFO clients

connected_clients: 48          → current client connections
cluster_connections: 0
maxclients: 10000              → configured connection limit
client_recent_max_input_buffer: 8
client_recent_max_output_buffer: 0
blocked_clients: 2             → clients in BLPOP/BRPOP/XREAD BLOCK
tracking_clients: 0
clients_in_timeout_table: 0
CLIENT LIST   → list all connected clients with their command state
CLIENT INFO   → current client's info
CLIENT KILL   → forcibly disconnect a specific client

blocked_clients > 0 is normal if you use blocking commands. A large number that is growing suggests workers are blocking and not consuming from queues.

connected_clients approaching maxclients is a warning. Increase maxclients in redis.conf or identify clients that are not disconnecting (connection leaks).


Summary

  • Redis uses persistent TCP connections — one connection per Redis instance, multiplexed via RESP
  • Always set commandTimeout — prevents commands from hanging indefinitely on connection issues
  • Set maxRetriesPerRequest: 2-3 — the default of 20 holds commands too long during outages
  • retryStrategy controls reconnection backoff — use exponential backoff with jitter, cap retries
  • Pub/Sub and blocking commands (BLPOP) require dedicated Redis instances
  • Always listen for error events — unhandled error on an EventEmitter crashes Node.js
  • For Sentinel: use the sentinels option; for Cluster: use ioredis.Cluster
  • Monitor connected_clients and blocked_clients via INFO clients
  • Design cache reads to degrade gracefully — catch Redis errors and fall back to the database

Next: P-11 — Monitoring and Observability — the INFO command section by section, SLOWLOG analysis, LATENCY HISTORY, and the 10 metrics every Redis dashboard must include.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.