Reliable Redis Connections in Node.js: Lazy Loading, Retry Logic & Circuit Breakers 🔦
Ali nazari

Ali nazari @silentwatcher_95

About: Just a tech

Location:
Earth 🌍
Joined:
Jul 30, 2022

Reliable Redis Connections in Node.js: Lazy Loading, Retry Logic & Circuit Breakers 🔦

Publish Date: May 7
6 0

If you’re using Redis in a Node.js application — especially in production — reliability isn’t optional.

A bad connection strategy can lead to memory leaks, crashes, or endless retry loops.

In this blog, I’ll show you how I built a resilient Redis integration in Express.js using ioredis, lazy loading, and a circuit breaker with opossum.

Reliable Redis Connections in Node.js: Lazy Loading, Retry Logic & Circuit Breakers 🔦

Why Care About Redis Connection Strategy?

At first, I used the classic approach:

const redis = new Redis(); // 🤷‍♂️ at the top level
Enter fullscreen mode Exit fullscreen mode

But I quickly ran into issues:

  • Connection errors on startup if Redis wasn’t ready.
  • Too many retry attempts without a proper limit.
  • No fallback strategy during Redis downtime.

So, I rebuilt the entire integration with three key ideas:

✅ Lazy Loading
✅ Retry Logic
✅ Circuit Breaker

Step 1: Lazy Load the Redis Client

instead of connecting at startup, I created a singleton getter that initializes Redis on first use:

import Redis from "ioredis";

export const rawRedis: () => Redis = (() => {
 let client: Redis | null = null;
 return () => {
  if (!client) {
   client = new Redis({
    retryStrategy(times) {
     if (times > 4) {
      logger.error("🔌 Redis gave up after 4 retries");
      return null; // stop retrying
     }
     // otherwise wait a bit before next retry
     const delay = Math.min(times * 200, 2000);
     logger.warn(
      `🔁 Redis retry #${times}, delaying ${delay}ms`,
     );
     return delay;
    },
    // Prevent endless per-command retries
    maxRetriesPerRequest: 1,
   });

   client.once("connect", () => logger.info("🔴 Redis connected"));
   client.on("error", (err) => logger.error("🛑 Redis error", err));
  }
  return client;
 };
})();
Enter fullscreen mode Exit fullscreen mode

Then in Express middleware:

app.use((req, res, next) => {
  req.redis = rawRedis(); // Lazy + singleton
  next();
});
Enter fullscreen mode Exit fullscreen mode

Why it’s good: This avoids unnecessary connections and ensures Redis is only initialized when needed.

Step 2: Add Retry Strategy to Handle Temporary Failures
The built-in retryStrategy in ioredis helps us reconnect after temporary issues:

retryStrategy(times) {
  const delay = Math.min(times * 200, 2000);
  return delay; // Retry with backoff
}
Enter fullscreen mode Exit fullscreen mode

This will retry forever (you can cap it if needed). We delay circuit breaking until we’re sure it’s not just a flaky network issue.

client = new Redis({
    retryStrategy(times) {
     if (times > 4) {
      logger.error("🔌 Redis gave up after 4 retries");
      return null; // stop retrying
     }
     // otherwise wait a bit before next retry
     const delay = Math.min(times * 200, 2000);
     logger.warn(
      `🔁 Redis retry #${times}, delaying ${delay}ms`,
     );
     return delay;
    },
    // Prevent endless per-command retries
    maxRetriesPerRequest: 1,
   });
Enter fullscreen mode Exit fullscreen mode

Step 3: Add Circuit Breaker with opossum

If Redis really goes down, retrying endlessly isn’t helpful. We need a circuit breaker to stop bombarding Redis with commands:

import CircuitBreaker from "opossum";
import { rawRedis } from "./redis";

/**
 * Execute an arbitrary Redis command.
 * @param cmd  — the Redis command name, e.g. "get", "set", "incr"
 * @param args — arguments for that command
 */
async function execRedisCommand(
 cmd: keyof Redis,
 ...args: unknown[]
): Promise<unknown> {
 const client = rawRedis();
 return (client[cmd] as (...args: unknown[]) => Promise<unknown>)(...args);
}

// Circuit breaker options
const breakerOptions = {
 timeout: 500, // if a command takes > 500ms, consider it a failure
 errorThresholdPercentage: 50, // % of failures to open the circuit
 resetTimeout: 30_000, // after 30s, try a command again (half-open)
};

/**
 * A “simple” Redis façade over rawRedis, wrapped in a circuit breaker.
 * Use this for your normal GET/SET/INCR calls in request handlers.
 */
export const redis = new CircuitBreaker(execRedisCommand, breakerOptions)
 .on("open", () => logger.warn("🚧 Redis circuit OPEN - fallback triggered"))
 .on("halfOpen", () => logger.info("🔄 Redis circuit HALF-OPEN"))
 .on("close", () => logger.info("✅ Redis circuit CLOSED"))
 .fallback(() => null); // return null if circuit is open;
Enter fullscreen mode Exit fullscreen mode

Understanding the Redis Command Wrapper with Circuit Breaker

Let’s break this code down:

/**
 * Execute an arbitrary Redis command.
 * @param cmd  - the Redis command name, e.g. "get", "set", "incr"
 * @param args - arguments for that command
 */
async function execRedisCommand(
  cmd: keyof Redis,
  ...args: unknown[]
): Promise<unknown> {
  const client = rawRedis();
  return (client[cmd] as (...args: unknown[]) => Promise<unknown>)(...args);
}
Enter fullscreen mode Exit fullscreen mode

What this does:

  • This function is a generic Redis command executor.

  • It lets us call any Redis command by name ("get", "incr", etc.) with arguments, without directly tying our route handlers to the Redis client.

  • Internally, it uses the rawRedis() lazy-loaded singleton to get the client.

// Circuit breaker options
const breakerOptions = {
  timeout: 500, // if a command takes > 500ms, consider it a failure
  errorThresholdPercentage: 50, // % of failures to open the circuit
  resetTimeout: 30_000, // after 30s, try a command again (half-open)
};
Enter fullscreen mode Exit fullscreen mode

What these options mean:

  • If any Redis command takes more than 500ms, it counts as a failure.

  • Once 50% of the recent requests fail, the circuit will open.

  • After 30 seconds, the circuit tries one command in a half-open state to test if Redis has recovered.

/**
 * A "simple" Redis façade over rawRedis, wrapped in a circuit breaker.
 * Use this for your normal GET/SET/INCR calls in request handlers.
 */
export const redis = new CircuitBreaker(execRedisCommand, breakerOptions)
  .on("open", () => logger.warn("🚧 Redis circuit OPEN - fallback triggered"))
  .on("halfOpen", () => logger.info("🔄 Redis circuit HALF-OPEN"))
  .on("close", () => logger.info("✅ Redis circuit CLOSED"))
  .fallback(() => null); // return null if circuit is open;
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Wraps the execRedisCommand in a circuit breaker using opossum.

  • It logs important circuit states (open, half-open, closed), so you know what’s happening in production.

  • If Redis is consistently failing and the circuit is open, we fall back to null to avoid throwing errors inside your route handlers.

Usage in Routes

You now call Redis like this:

const count = await redis.fire("incr", "global:counter");
if (count === null) {
  return res.send({ counter: "unavailable" });
}
Enter fullscreen mode Exit fullscreen mode

This avoids trying to use Redis when it’s clearly down or under heavy load — improving resilience and user experience.

The breaker acts as a second layer of defense: it only opens after a series of failed commands and gives Redis breathing room before trying again.

We now have:

  • Lazy, singleton Redis instance
  • Safe retry logic
  • A circuit breaker to avoid hammering Redis when it’s clearly down

This setup helped me turn Redis from a potential single point of failure into a robust service layer. It gracefully handles outages and avoids crashing my app.

Reliable Redis Connections in Node.js: Lazy Loading, Retry Logic & Circuit Breakers


Hey! I recently created a tool called express-admin-honeypot.

Feel free to check it out, and if you like it, consider leaving a generous star on my GitHub! 🌟


Let’s connect!!: 🤝
LinkedIn
GitHub

Comments 0 total

    Add comment