← Back to Blog

Redis Will Go Down. Make Sure Your Node.js App Doesn't.

Redis Will Go Down. Make Sure Your Node.js App Doesn’t.

A practical framework for deciding what should happen when your cache, session store, or job queue can’t reach Redis.

Somewhere in your codebase there’s a line that looks like await redis.get(key) with nothing around it - no try/catch, no timeout, no fallback. It’s been there for two years. It’s fine, right up until the day Redis does a failover, or your managed provider runs a maintenance window at 2 PM on a Tuesday, and that one unguarded call takes checkout down with it.

I’ve read enough postmortems to recognize this pattern on sight: Redis wasn’t holding anything irreplaceable. It was a cache. Nobody ever designed for what happens when the cache says no, so the rest of the app never learned how to say yes anyway.

That’s the whole article in one sentence: Redis should be treated as a performance and availability dependency, not a single point of failure - unless you’ve deliberately made it your system of record. Everything else here is just working out what that means for the specific things you’re storing in it.

Classify before you architect

This is the decision that needs to happen before any failure-handling code gets written, and it’s the one teams skip most often. Not all Redis usage deserves the same failure strategy, because not all Redis usage costs the same when it disappears for thirty seconds.

What’s in Redis If Redis fails What you actually do
Cache App gets slower, not down Fall back to the DB, repopulate on the way back
Session User might get logged out HA (High Availability) Redis, plus a real plan for what “logged out” costs you
Rate limiting Limits behave inconsistently for a while Fail open or closed - a security call, not a technical one
Distributed lock Actively dangerous if you get it wrong Fail safe - never assume a lock is held that you can’t prove
Queue (BullMQ, etc.) Jobs delay, or quietly vanish HA (High Availability) Redis + persistence, not just “Redis is up”
Pub/Sub Messages that were never guaranteed just aren’t there Don’t build a durable event bus out of it
Primary business data Real, permanent data loss Replication, persistence, and probably a second thought

Go through your keyspace and put every prefix into one of these buckets. In my experience, the exercise itself is usually worth more than any code that follows, because it’s the point where people notice they’ve quietly promoted Redis to “primary store” for something - a feature flag, an idempotency key, a half-built session store - without ever deciding to.

What fallback actually looks like

The cache case is the one everyone gets right in theory and wrong in practice, usually on the write side.

async function getUser(userId) {
  try {
    const cached = await redis.get(`user:${userId}`);
    if (cached) return JSON.parse(cached);
  } catch (err) {
    logger.warn('redis read failed, falling back to db', { err: err.message, userId });
  }

  const user = await db.users.findById(userId);

  // fire-and-forget - a failed cache write should never fail the request
  redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 300).catch((err) => {
    logger.warn('redis write failed, cache not repopulated', { err: err.message, userId });
  });

  return user;
}

Nothing clever there. The read path gets a try/catch almost by instinct; the write-back is the one people forget, which means the first Redis blip after a deploy throws an unhandled rejection from a function nobody’s watching.

None of that matters if your client configuration works against it, and this is where teams do the classification correctly and then get undone by defaults they never looked at. Two clients dominate the Node.js side of this - ioredis and the official redis package (node-redis) - and their defaults aren’t the same, which bites people who copy a config snippet from the wrong one.

ioredis, set up to fail fast instead of quietly queuing:

const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST,
  maxRetriesPerRequest: 2,     // don't let one call retry forever
  enableOfflineQueue: false,   // reject immediately instead of queuing while disconnected
  connectTimeout: 3000,
  retryStrategy(times) {
    if (times > 10) return null; // stop reconnecting, give up
    return Math.min(times * 200, 3000);
  },
});

redis.on('error', (err) => {
  // fires far more often than people expect - log it, don't let it crash you
  logger.error('redis connection error', { err: err.message });
});

enableOfflineQueue: false is the setting people miss most. Leave it at its default (true) and every command issued while Redis is unreachable just queues up in memory, waiting for a reconnect that could be minutes away - so your “fast” cache read now hangs instead of failing immediately into your DB fallback. For a cache lookup, fail fast almost every time. For a worker that’s fine sitting idle until Redis comes back, you may genuinely want the opposite.

The official redis client uses different names for the same ideas - reconnectStrategy instead of retryStrategy, disableOfflineQueue: true instead of enableOfflineQueue: false - so check which client you’re actually running before pasting in a config from a blog post. Including this one.

The shape of it

Once the classification work is done, the flow is almost boringly consistent:

Application
    |
    v
Redis call --- success --> return the value
    |
  failure
    |
    v
Can this data type degrade safely?
    |
    +-- yes --> fall back to DB / API / safe default, log it
    |
    +-- no  --> fail the request explicitly, don't pretend to succeed

That second branch is the one that’s easy to skip. It’s tempting to write one generic “on Redis failure, do X” handler and apply it everywhere, but “fall back to a safe default” and “fail loudly” are both legitimate outcomes - which one is correct depends entirely on what you classified earlier. A lock that silently “succeeds” when Redis is unreachable isn’t graceful degradation. It’s a bug wearing a try/catch as a disguise.

What doesn’t show up in a try/catch

A few things I’d treat as non-negotiable at real scale, none of which are visible in the code above. Retries alone aren’t enough - during a real outage they just mean every request pays the full timeout while hammering a service that’s already struggling to come back, so put a circuit breaker in front of Redis too. A library like opossum works fine, or a hand-rolled version - it’s genuinely not much code - that trips after N consecutive failures, fails fast immediately, and periodically lets a request through to check for recovery. Track fallback invocations as their own metric, not just error logs; if a third of your traffic is quietly falling back to Postgres because Redis has been flaky all morning, you want a dashboard telling you that before your DB connection pool tells you instead. Alert on the Redis error rate specifically, separate from general application errors - a cache falling back gracefully shouldn’t page anyone, but a cache falling back at ten times its normal rate should, even though every individual request is technically still succeeding. And actually kill Redis in staging. Not reasoning about it in a design doc - running it during a load test and watching what happens to p99 latency and error rates in practice. Most of the surprises I’ve seen here weren’t missing try/catch blocks. They were fallback paths that had never once been exercised, discovering their own bug the first time they ran for real.

The bottom line

None of this is really about Redis. It’s about being honest, category by category, about what happens on the failure branch - and refusing to let “cache” quietly become “database” just because nobody revisited the decision after traffic grew. Redis being fast and convenient is exactly why it creeps into that role. Treat everything new you put into it as a question - cache, or system of record? - and answer it before you’re forced to answer it at 2 AM.