Skip to content
Dev Tools Article

Your Redis Wrapper Is Probably Lying to You

A Node.js client rewrite shows why returning null during outages is data loss, and why the big clients' defaults are no safer.

Rachel Goldstein
Rachel Goldstein
Dev Tools Editor · Aug 26, 2026 · 5 min read
Your Redis Wrapper Is Probably Lying to You

Every Redis wrapper eventually has to answer one question: what happens to a command when the server isn't there? There are only three honest-ish answers — throw, wait, or lie — and a small Node.js library just published a detailed confession about picking the third one.

The package is @pinceladasdaweb/redis, a convenience layer over ioredis with health checks, JSON helpers and reconnection logic. It's tiny (about 1,700 npm downloads last month), so the library itself isn't the story. The bug is, because the same shape of bug is sitting in a lot of in-house redis.js files right now.

The polite way to lose data

The original code guarded every command with a health flag. If Redis looked unhealthy, it logged a warning and returned null. The README even advertised it: methods return null when Redis isn't healthy. So set, incr and lpush all resolved cleanly during an outage while doing nothing at all. Author Pedro Rogério's summary is the best line in the post-mortem: a write that resolves while doing nothing is "data loss with good manners."

Reads were quietly worse. A null from get was supposed to mean "key doesn't exist" — now it also meant "Redis was down," and nothing in the return type let the caller tell the two apart. Cache-aside code would treat every outage as a miss, refetch from the primary database, try to repopulate the cache, and get another silent null for its trouble.

Layered on top was a hand-rolled reconnection loop that competed with ioredis's own. Every error or close event spun up a fresh ioredis client without destroying the previous one, so a one-minute blip left a pile of orphaned clients each running its own retry schedule. The ping that was supposed to validate a reconnect got skipped after the first attempt, leaving isConnected = true during failures. And disconnect() called quit(), which emitted close, which scheduled a reconnect — the client wouldn't stay dead.

Throw, wait, or lie

What makes this worth reading beyond one repo is that the two mainstream Node clients default to the middle option, and plenty of teams don't know it.

ioredis ships with enableOfflineQueue: true and maxRetriesPerRequest: 20. Commands issued while the socket is down go into an in-memory queue and get replayed on reconnect; only after 20 reconnection attempts does the queue flush with a MaxRetriesPerRequestError. node-redis does the same by default, and Redis's own production guidance spells out the sharp edge: a non-idempotent command can execute on the server, the socket dies before the reply, and the client replays it after reconnecting. Their prescription is disableOfflineQueue: true, optionally on a dedicated connection for the commands you can't afford to double-send.

So the ecosystem default is "hang and hope." That's defensible for a background worker that would rather stall than error, and terrible for a request handler. BullMQ documents exactly this: Queue.add() during an outage doesn't fail, it waits, and an HTTP request waiting on it just sits there. Their recommendation is enableOfflineQueue: false on the Queue connection so add throws and the API can return a 503.

Rogério's library started from that hanging default and "fixed" it in the wrong direction — instead of surfacing the wait as a failure, it swallowed it as a success. The rewrite lands on the first option: any command issued while client.status !== 'ready' throws a RedisClientError with a stable REDIS_UNAVAILABLE code, and the docs tell you to branch on the code, never the message text. Nothing is sent, nothing is queued, nobody gets a fake OK.

The best resilience commit deleted code

The other lesson is older than Redis: don't reimplement what your driver already owns. ioredis has had retryStrategy, reconnectOnError and a status property for years. The rewrite ripped out the custom reconnection layer entirely, let ioredis drive reconnects, and reduced the wrapper to reading state. Health checks stopped issuing serialized PINGs and started reading client.status === 'ready', which is free. Per the post-mortem, recovery after a server-side kill dropped from roughly two seconds to about 110 milliseconds — a number I can't independently reproduce, but the direction is what you'd expect once the wrapper stops fighting the driver.

The cleanup shook loose the usual crop of secondary bugs: maxRetryAttempts: 0 was falsy and therefore meant "retry forever"; scanStream didn't apply keyPrefix to MATCH, so scans read other applications' keys; JSON.stringify(undefined) returns undefined, which got stored as an empty string and poisoned the key until its TTL expired. Pub/sub moved to a dedicated connection, because a subscribed ioredis connection can only run subscription commands — the docs have said so forever, and wrappers keep forgetting.

Runtime dependencies went from three to one (ioredis; the pino logger no longer ships to production), and the current 4.x line sits on ioredis 6 and Node 22+.

What to do with your own wrapper

Grep for the pattern. Any if (!healthy) return null — or return undefined, or return false, or catch (e) { logger.warn(e) } around a write — is the same bug. Then decide per call site, not globally:

  • Cache-aside reads can legitimately degrade to a miss. But make it explicit: throw from the client, catch at the cache layer, and emit a metric so "Redis has been down for an hour" doesn't look like a cold cache.
  • Writes that are source of truth — sessions, rate-limit counters, distributed locks, queue jobs — must throw. A lock you "acquired" against a dead server is worse than no lock.
  • Request handlers should fail fast. On ioredis that's enableOfflineQueue: false (and often a small maxRetriesPerRequest for the in-flight case); on node-redis it's disableOfflineQueue: true. Return a 503 and let the load balancer do its job.
  • Background workers can keep the queue on, but cap it and alarm on MaxRetriesPerRequestError.

Then test the way this rewrite eventually did: not by mocking error events, but by running CLIENT KILL (or docker kill) against a real server mid-test and asserting two things — that a write during the gap rejected with a code you can branch on, and that the connection count afterwards is still one. The second assertion is the one that would have caught the orphaned-client storm on day one.

The uncomfortable takeaway is that the "resilient" wrapper was less resilient than bare ioredis. Most of the value of these libraries is ergonomic — JSON helpers, a lock primitive, key prefixes. The moment one starts making availability decisions on your behalf, read the source. A null is a very quiet way to say "I threw your data away."

Sources & further reading

  1. My Redis library said the write succeeded. Redis was down - anatomy of a Node.js client rewrite — dev.to
  2. pinceladasdaweb/redis — github.com
  3. Node.js production usage — redis.io
  4. Failing fast when Redis is down — docs.bullmq.io
  5. ioredis README — ioredis.readthedocs.io
Rachel Goldstein
Written by
Rachel Goldstein · Dev Tools Editor

Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading