What this module covers: Your Node.js service processes 50,000 events/second. Every event requires a database write. That means 50,000 PostgreSQL queries/second. A naive implementation opens a new connection for every query — connection establishment costs 5–10ms and PostgreSQL cannot handle 50,000 simultaneous connections. Connection pooling is the mechanism that multiplexes thousands of queries over dozens of connections. This module covers pg-pool sizing mathematics, Redis connection management, PM2 cluster lifecycle, graceful shutdown sequences, and the Kubernetes liveness vs readiness probe distinction that prevents traffic routing before your service is ready.
PostgreSQL: spawns a new OS process per connection (~5MB RAM, ~10ms setup cost)
Redis: opens a new TCP socket per connection (~0.5ms setup, persistent)
MongoDB: opens a new socket, negotiates auth (~2ms)
Without pooling at 50,000 queries/second:
50,000 new PostgreSQL connections/second × 10ms = 500 seconds of connection overhead per second of operation — impossible
At any instant, thousands of concurrent queries would require thousands of simultaneous PostgreSQL processes: 5,000 connections × 5MB = 25GB RAM just for PostgreSQL process overhead
Connection pooling solves this by reusing a small set of long-lived connections for many short-lived queries.
A connection pool is the taxi rank outside a stadium after the final whistle — too few cabs and the queue snakes around the block even though every individual ride is quick.
At 5,000 queries/second with 10ms average query duration: pool_size = 5,000 × 0.010 = 50 connections
This is Little's Law applied to database connections. If you need more throughput or have longer queries, you need more connections — up to the database's limit.
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL,
// Pool size: sized to your throughput target // = (target_queries_per_sec × avg_query_duration_sec) max: 50,
// Connection idle timeout: close idle connections after 30s // Prevents accumulating connections that just hold resources idleTimeoutMillis: 30_000,
// Connection acquisition timeout: fail fast if pool is exhausted // Without this, queries wait forever when pool is full connectionTimeoutMillis: 5_000,
// Minimum connections to keep warm // Prevents cold-start latency for idle services min: 5,
// Disconnect and reconnect after N transactions // Prevents long-lived connections accumulating server-side state maxUses: 7_500,
// Controls whether the Node.js process is allowed to exit while this pool // still has idle or active clients. `false` keeps the event loop alive on // their behalf — it performs no health check of any kind, it just affects // process exit behavior. allowExitOnIdle: false, });
// Monitor pool utilization in production const poolUtilizationGauge = new Gauge({ name: 'db_pool_utilization_ratio', help: 'DB pool utilization (active / max)', });
const poolWaitingGauge = new Gauge({ name: 'db_pool_waiting_count', help: 'Queries waiting for a connection', });
setInterval(() => { // pg-pool exposes pool state via these properties const total = pool.totalCount; // total connections (idle + active) const idle = pool.idleCount; // available connections const waiting = pool.waitingCount; // queries queued for a connection
When all pool connections are busy, pool.connect() waits. At high throughput, this creates a latency cascade: query takes 15ms → pool slot held for 15ms → at 50 queries/pool × 15ms = 750ms of total query time per second → pool fully booked at 3,333 queries/second.
P99 latency spikes (queries waiting for a connection)
connectionTimeoutMillis errors (pool exhausted for too long)
Not as high CPU or network errors — the service appears healthy from the outside while queries queue silently
Production story: During a festival traffic spike, a UPI payment service scaled its Kubernetes deployment from 4 replicas to 8 to absorb load, each pod running the max: 50 pool from the example above. Nobody re-ran the pool-sizing math against the replica count: 8 × 50 = 400 potential simultaneous connections to a single PostgreSQL instance configured with the default max_connections of 100. Within seconds of the new pods coming up, Postgres started rejecting new connections cluster-wide with FATAL: sorry, too many clients already — including from pods that had been running fine, because the limit is on the database, not per-service. The fix was PgBouncer in front of Postgres (below), decoupling "connections the app thinks it has" from "connections Postgres actually has to hold open."
max: 50 on a single pod's pg.Pool is a per-process limit. It says nothing about how many processes there are. Run 8 replicas of the same service, each with max: 50, and the true ceiling against Postgres is 400 simultaneous connections — against a max_connections default that ranges from 100 to 300 depending on the managed Postgres provider. Every example pool configuration in this module has assumed a single process; at any real replica count that assumption breaks.
PgBouncer sits between your application and Postgres as a lightweight external pooler, in transaction pooling mode: it maintains a small number of real Postgres connections and multiplexes many more client connections onto them, handing a real connection to a client only for the duration of a single transaction.
Without PgBouncer: 8 replicas × max: 50 = 400 potential connections → Postgres max_connections: 100 → FATAL: sorry, too many clients already (at replica count, not at query volume)
With PgBouncer (transaction pooling): 8 replicas × max: 50 = 400 client-side connections → PgBouncer → 20-30 real Postgres connections → Postgres sees a small, stable connection count regardless of how many app replicas scale up
[pgbouncer] pool_mode = transaction # hand back the connection after each transaction, not each session max_client_conn = 1000 # connections PgBouncer accepts from your app replicas default_pool_size = 25 # real Postgres connections PgBouncer maintains per database
The trade-off: transaction pooling means session-level state (SET commands, advisory locks held across statements, LISTEN/NOTIFY) does not survive between queries from the same client connection, because the underlying Postgres connection can be handed to a different client between transactions. Anything in this module relying on session state (like maxUses recycling) needs to be re-evaluated against that constraint before adopting PgBouncer.
Long-lived database connections accumulate state: temporary tables (from past SET commands), prepared statements, and sometimes server-side session state.
maxUses: 7500 causes pg-pool to destroy and recreate a connection after 7,500 uses. This prevents state accumulation without the overhead of creating a new connection per query.
// Verify pool connections are healthy before use // pg-pool has no built-in "validate on acquire" hook — the validation has to // be done explicitly. `connect` fires once per physical connection (not per // query), so a cheap check here catches a connection that went stale without // adding overhead to every query. const pool = new pg.Pool(config);
pool.on('connect', (client) => { client.query('SELECT 1').catch((err) => { logger.error({ err: err.message }, 'New pool connection failed health check'); }); });
pool.on('error', (err) => { // Fires for errors on idle clients (e.g. the network dropped underneath them) logger.error({ err: err.message }, 'Idle pool client emitted an error'); });
// Manual health check on startup (wait for pool to be ready before serving traffic) async function waitForDatabase(retries = 10) { for (let i = 0; i < retries; i++) { try { await pool.query('SELECT 1'); logger.info('Database connection pool ready'); return; } catch (err) { logger.warn({ attempt: i + 1, err: err.message }, 'Database not ready, retrying...'); await new Promise(r => setTimeout(r, 2_000)); } } throw new Error('Database connection failed after retries'); }
// BAD: N network round trips for N commands for (const tx of transactions) { await redis.set(`tx:${tx.hash}`, JSON.stringify(tx), 'EX', 3600); } // N × RTT = high latency for large batches
// GOOD: pipeline — all commands sent in one batch const pipeline = redis.pipeline(); for (const tx of transactions) { pipeline.set(`tx:${tx.hash}`, JSON.stringify(tx), 'EX', 3600); } await pipeline.exec(); // 1 RTT regardless of batch size
// Restart policy autorestart: true, watch: false, // don't watch files in production max_restarts: 10, // stop restarting after 10 crashes in a row min_uptime: '10s', // must be up for 10s to count as successful start }] };
# reload: rolling restart — new worker starts, accepts traffic, # old worker receives SIGTERM and drains in-flight requests # Zero downtime pm2 reload blockchain-indexer
How reload works:
PM2 starts a new worker instance
New worker starts, begins accepting connections (via SO_REUSEPORT)
PM2 sends SIGTERM to old worker
Old worker: stops accepting new connections, finishes in-flight requests
Old worker exits cleanly
Repeat for each worker in the cluster
This is why implementing graceful shutdown correctly is essential for pm2 reload to work.
A Node.js service has multiple resources that must be closed in the correct order on shutdown.
// The complete graceful shutdown sequence async function gracefulShutdown(signal) { logger.info({ signal }, 'Shutdown signal received');
// Step 1: Stop accepting new connections // This prevents new requests from starting while we drain await new Promise((resolve, reject) => { httpServer.close((err) => err ? reject(err) : resolve()); }); logger.info('HTTP server stopped accepting connections');
// Step 2: Drain in-flight Kafka consumers // Wait for currently-processing messages to complete before closing await kafkaConsumer.disconnect(); logger.info('Kafka consumer disconnected');
// Step 3: Drain active database queries // Wait for currently-running queries to complete // pg-pool.end() waits for all connections to become idle, then closes them await pool.end(); logger.info('Database pool closed');
// Hard timeout: if graceful shutdown takes too long, force exit const SHUTDOWN_TIMEOUT = 15_000; setTimeout(() => { logger.error('Graceful shutdown timed out — forcing exit'); process.exit(1); }, SHUTDOWN_TIMEOUT).unref(); // .unref() prevents this timer from keeping process alive
Kubernetes uses two types of health probes with critically different semantics.
Liveness probe: "Is this pod still alive and should it be restarted?"
If liveness fails: Kubernetes kills and restarts the pod
Use for: detecting deadlocks, infinite loops, hung processes
Be conservative: don't check dependencies (database) in liveness
Readiness probe: "Is this pod ready to receive traffic?"
If readiness fails: Kubernetes removes the pod from load balancer rotation (but does NOT restart it)
Use for: checking database connectivity, cache warmup, dependency availability
A pod can be live but not ready (database is down → no traffic, but no restart needed)
// Health check endpoints fastify.get('/healthz/live', async (req, reply) => { // Liveness: only check if the process is alive and not deadlocked // This should always return 200 unless the process itself is broken reply.code(200).send({ status: 'alive', pid: process.pid }); });
# kubernetes/deployment.yaml spec: containers: - name: blockchain-indexer livenessProbe: httpGet: path: /healthz/live port: 3000 initialDelaySeconds: 10 # wait 10s before first check periodSeconds: 10 # check every 10s failureThreshold: 3 # restart after 3 consecutive failures
readinessProbe: httpGet: path: /healthz/ready port: 3000 initialDelaySeconds: 5 # start checking readiness after 5s periodSeconds: 5 # check every 5s failureThreshold: 2 # remove from rotation after 2 failures successThreshold: 1 # add back after 1 success
The startup scenario:
t=0s: Pod starts, Node.js begins loading modules t=5s: First readiness check: database pool connecting → 503 → not in rotation t=10s: Database pool ready, Redis connected → 200 → added to rotation t=10s: First liveness check: process alive → 200 → no restart
If database goes down at t=300s: t=305s: Readiness fails → removed from rotation (no new traffic) t=305s: Liveness still passes (process is alive, just can't reach DB) t=310s: Liveness still passes → pod NOT restarted t=350s: Database recovers → readiness passes → back in rotation (Zero pod restarts for a transient database outage)
The graceful shutdown sequence above assumes that by the time SIGTERM arrives, no new traffic is being routed to the pod. That assumption is false for a brief but real window. When Kubernetes decides to terminate a pod, two things happen concurrently, not in order: the kubelet sends SIGTERM to the container, and the endpoints controller starts removing the pod from Service endpoints (and therefore from kube-proxy / load balancer routing). Propagating that removal across every node's kube-proxy and every upstream load balancer is not instantaneous — it can take anywhere from tens of milliseconds to a few seconds depending on cluster size and CNI. During that window, the pod can still receive new requests while it has already begun (or is about to begin) shutting down.
A preStop hook closes this gap by delaying the SIGTERM itself until the pod has almost certainly been deregistered:
# kubernetes/deployment.yaml spec: containers: - name: blockchain-indexer lifecycle: preStop: exec: # Sleep gives the endpoints controller / load balancers time to # deregister this pod BEFORE SIGTERM starts the drain sequence. # SIGTERM is not sent until this hook returns. command: ["sh", "-c", "sleep 5"] terminationGracePeriodSeconds: 30 # must cover: preStop + graceful shutdown + buffer
terminationGracePeriodSeconds has to budget for the whole sequence: the preStop sleep, plus the application's own graceful shutdown (SHUTDOWN_TIMEOUT above), plus a safety margin — not just the shutdown code's own timeout. If terminationGracePeriodSeconds is shorter than preStop sleep + shutdown time combined, Kubernetes sends SIGKILL before the graceful sequence finishes, and in-flight requests are dropped exactly the way this section is trying to prevent.
One of the most common causes of a Node.js pod getting OOMKilled in Kubernetes has nothing to do with a memory leak — it's a mismatch between the container's memory limit and what V8 thinks its heap budget is.
By default, V8 sizes its heap based on the host machine's total available memory, not the container's resources.limits.memory. A container capped at 512Mi can still have V8 targeting a default heap ceiling (historically ~1.5–2GB on 64-bit, though modern Node versions since ~v18/v20 have improved container-awareness) sized for a much larger machine. V8 doesn't start trimming the heap until it approaches its own ceiling — long before that, the container's cgroup memory limit is hit and the kernel OOM-kills the process, with no GC pressure or heap-growth warning ever surfacing in application logs.
# kubernetes/deployment.yaml spec: containers: - name: blockchain-indexer resources: requests: memory: '512Mi' limits: memory: '512Mi' # hard cgroup limit — OOMKilled if exceeded env: - name: NODE_OPTIONS # Explicitly cap V8's old-space size well BELOW the container limit, # leaving headroom for the process's non-heap memory: stack, buffers, # native addons, worker_threads, and V8's own internal overhead. # Rule of thumb: --max-old-space-size at roughly 75-80% of the container limit. value: '--max-old-space-size=384'
The remaining 20–25% of headroom is not waste — it covers memory --max-old-space-size doesn't account for at all: the C++ stack, Buffer/ArrayBuffer allocations (which live outside V8's managed heap), and any worker_threads, each of which gets its own heap on top of the main thread's. Sizing --max-old-space-size equal to (or worse, above) the container limit is a reliable way to get OOMKilled while GC logs still show "healthy" heap usage relative to V8's own — wrong — ceiling.
What happened: During normal operation, the service handled load because average query time was actually 6ms (not 8ms as designed for). Required pool: 3,000 × 0.006 = 18 connections. The pool of 20 had a 10% safety margin.
During a marketing push, traffic doubled to 6,000 queries/second. Required pool: 6,000 × 0.006 = 36 connections. Available: 20.
Pool exhaustion onset: when waiting_count > 0 At 6,000 qps with 20 connections at 6ms each: Pool throughput: 20 / 0.006 = 3,333 queries/second max Deficit: 6,000 - 3,333 = 2,667 queries/second queuing
After 1 second: 2,667 queries waiting After 5 seconds: 13,335 queries waiting connectionTimeoutMillis = 5,000ms → all queued queries start failing with timeout
Symptom: p99 latency jumped from 12ms to 5,200ms (the connection timeout). CPU was at 30%. Database was at 25% capacity. The bottleneck was invisible without monitoring db_pool_waiting_count.
The fix:
// Correctly sized pool for the traffic target const pool = new pg.Pool({ max: 50, // sized for 6,000 qps at 8ms = 48 connections + buffer connectionTimeoutMillis: 2_000, // fail fast (2s not 5s) idleTimeoutMillis: 30_000, });
// Added alerting if (pool.waitingCount > 0) { alert(`DB pool exhausted: ${pool.waitingCount} queries waiting`); }
Additionally, connectionTimeoutMillis was reduced from 5,000ms to 2,000ms. The long timeout was hiding the exhaustion — queries waited 5 seconds before failing, making the P99 look catastrophic while masking the root cause (pool size).
pool_size = target_qps × avg_query_duration_sec. Recheck when traffic grows.
db_pool_waiting_count
First metric to add. Any waiting = pool undersized for current load.
connectionTimeoutMillis
Set to 2–3s. Long timeouts hide exhaustion and make P99 look catastrophic.
maxUses
Recycle connections after N uses to prevent server-side state accumulation.
ioredis pipelining
Batch Redis commands in one RTT. Use pipeline() for multi-key operations.
PM2 pm2 reload
Rolling restart with zero downtime. Requires correct graceful shutdown implementation.
Graceful shutdown
Stop accepting → drain Kafka → drain DB pool → close Redis → exit. Hard timeout as fallback.
Liveness probe
Is the process alive? Check: process responsiveness only. Never check dependencies.
Readiness probe
Is the service ready for traffic? Check: database, Redis, Kafka. Fail gracefully during startup.
pool.end()
Waits for all active queries to complete before closing connections. Safe for shutdown.
The connection layer determines capacity. Module 14 covers the other end of the scale — ultra-lightweight V8 isolates for edge deployments that need zero cold starts and globally distributed intake proxies.
When configuring a Kubernetes deployment for a Node.js service, what is the critical difference between a liveness probe and a readiness probe, and what should be checked in each?
A Node.js service has an average database query duration of 15ms. During a marketing event, traffic is expected to spike to 4,000 database queries per second. According to Little's Law applied to pool sizing, what is the mathematical minimum PostgreSQL connection pool size required to handle this throughput without queuing?
Why is it crucial to set a relatively short connectionTimeoutMillis (e.g., 2,000ms) on the PostgreSQL connection pool rather than a long default (e.g., 30 seconds)?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.