npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pinceladasdaweb/redis

v4.3.0

Published

A resilient Redis client for Node.js built on ioredis, with automatic reconnection, health checks and JSON helpers.

Downloads

1,741

Readme

Redis

A resilient Redis client for Node.js built on ioredis, with driver-owned automatic reconnection, fail-fast structured errors, observable connection lifecycle events and JSON helpers.

Every reliability claim in this README is enforced by the integration suite against a real Redis — including a test that kills the connection server-side (CLIENT KILL) and proves full recovery.

Features

  • Driver-owned reconnection: a single ioredis client per connection cycle; exponential backoff (baseRetryDelaymaxRetryDelay), configurable attempt limit, automatic recovery after server-side kills and failovers (READONLY replies reconnect and resend the failed command).
  • Fail-fast structured errors: commands issued while disconnected reject immediately with code: 'REDIS_UNAVAILABLE' — a write never looks successful when nothing happened. No network round-trip is added to the hot path.
  • Observable lifecycle: RedisClient is an EventEmitterready, close, reconnecting, end and connectionError tell you exactly what the connection is doing.
  • Dedicated connections when they matter: blocking stream reads (BLOCK) never stall the shared connection, and withDedicatedConnection() gives you isolated WATCH/MULTI/EXEC optimistic locking that actually works under concurrency.
  • Pub/Sub that survives outages: subscriptions live on a dedicated connection and are automatically restored after reconnections — enforced by a server-side CLIENT KILL test.
  • Distributed locking (single instance): withLock()/acquireLock() with SET NX PX acquisition and token-checked Lua release — a holder can never release or extend someone else's lock.
  • Registered Lua: defineScript()/runScript() send the SHA instead of the script body, reload themselves on NOSCRIPT and survive reconnections — for atomic compare-and-set, rate-limiter windows and anything else on a hot path.
  • Bring your own logger: inject any pino/winston/bunyan instance; the built-in fallback is a dependency-free leveled console logger.
  • JSON helpers: setJson/getJson/setexJson with explicit serialization — never magic.
  • Cache-aside with stampede protection: getOrSet/getOrSetJson return the cached value or produce-and-store it — and with { lock: true }, concurrent misses collapse into a single producer call.
  • Bulk deletion done right: deleteByPattern uses SCAN + UNLINK in batches (non-blocking, prefix-aware) instead of KEYS.
  • Sentinel support: pass sentinels + name and the client rides ioredis' native high-availability failover.
  • Prefixed keyspace scan: getAllStream(pattern) dumps your keys (and only yours — keyPrefix is honored, unlike raw SCAN), skipping non-string types gracefully.
  • Rankings without the footguns: sorted-set scores return as real numbers (infinities included) and withScores gives you { member, score } pairs instead of a flat array.
  • TypeScript declarations: hand-maintained index.d.ts, checked with tsc --strict in CI.
  • One runtime dependency: ioredis. Nothing else.

Installation

npm install @pinceladasdaweb/redis

Works with import and require:

import RedisClient from '@pinceladasdaweb/redis'
// or
const { RedisClient } = require('@pinceladasdaweb/redis')

Requires Node.js >= 22 and Redis >= 7.0.

The Redis floor is this library's own, not the driver's — ioredis 6 itself reaches back to 6.2. Two features shipped in Redis 7.0 are used as if guaranteed, and both were verified against a real 6.2 server before writing this line: XAUTOCLAIM's third reply field (the ids whose data is gone) does not exist, and notify-keyspace-events rejects the n class that subscribeToKeyEvents('new', …) needs. On 6.2 the second fails loudly; the first would quietly report an empty list of lost entries, which is worse.

Two consequences of the ioredis 6 major worth knowing:

  • RESP3 is the default wire protocol. Every reply this library translates (config get, WITHSCORES ranges, xpending, xautoclaim) was measured under both protocols and comes back in the same shape, so nothing here changes. If you need the old protocol anyway, protocol: 2 is forwarded to the driver like any other option.
  • redis.client hands you an ioredis 6 instance. If your application talks to ioredis directly as well, keep it on the same major or you will install two copies of the driver.

Quick start

import RedisClient from '@pinceladasdaweb/redis'

const redis = new RedisClient({ host: '127.0.0.1', port: 6379 })

redis.on('reconnecting', (delay) => console.log(`redis down, retrying in ${delay}ms`))

await redis.connect()

await redis.set('greeting', 'hello')
console.log(await redis.get('greeting')) // 'hello'

await redis.setJson('user:1', { name: 'Ada' })
console.log(await redis.getJson('user:1')) // { name: 'Ada' }

await redis.disconnect()

Examples

Every example below is a runnable script that asserts its own outcome — if the documented behavior ever breaks, the example fails instead of quietly printing something wrong. Start a server (docker compose up -d) and run them all with npm run examples, or one at a time:

node "examples/5 - cache-stampede/index.mjs"

| # | Example | What it shows | | --- | --- | --- | | 1 | connection | Connecting, lifecycle events (ready/close/end), health probe, clean shutdown | | 2 | strings-and-expiration | set/get, setex, ttl/persist, atomic counters, mset/mget | | 3 | json-documents | setJson/getJson/setexJson and why serialization stays explicit | | 4 | cache-aside | getOrSetJson: miss produces, hit serves — with the producer call counted | | 5 | cache-stampede | 50 concurrent misses: 50 database hits without the lock, 1 with { lock: true } | | 6 | cache-invalidation | deleteByPattern (SCAN + UNLINK) and dumping the keyspace with getAllStream | | 7 | hashes | Partial updates and atomic field increments | | 8 | lists-and-sets | Queues with lists, membership with sets | | 9 | distributed-lock | Two workers, one critical section: withLock, contention, token-checked release | | 10 | long-running-lock | autoExtend: a 1500ms job safely holding a 500ms lock | | 11 | pubsub | Channel and pattern subscriptions, handlers vs events, unsubscribing | | 12 | streams | Consumer groups end to end: xreadgroup, xack, pending entries, xclaim, xtrim | | 13 | transactions | multi() batches and real optimistic locking, including an aborted conflict | | 14 | resilience | Fail-fast REDIS_UNAVAILABLE, error codes and graceful degradation | | 15 | custom-logger | Injecting your own logger and proving the hot path stays silent | | 16 | rate-limiting | A fixed-window limiter built from incr + expire, correct under bursts | | 17 | leaderboard | Sorted sets: rankings, pagination by score, infinite scores, priority queue, trimming |

Constructor options

Connection

| Option | Type | Default | Description | | --- | --- | --- | --- | | host | string | — | Redis server hostname | | port | number | — | Redis server port | | username | string | — | Authentication username | | password | string | — | Authentication password | | db | number | 0 | Database number | | keyPrefix | string | '' | Prefix applied to every key, including getAllStream scans | | connectionName | string | — | CLIENT SETNAME value; makes the client identifiable in CLIENT LIST |

Cluster

Pass nodes and the client talks to a Redis Cluster: slots are discovered, MOVED and ASK redirections are followed (up to maxRedirections, 16 by default) and the map is refreshed when the topology changes.

const redis = new RedisClient({
  nodes: [
    { host: 'node-1', port: 6379 },
    { host: 'node-2', port: 6379 }
  ],
  keyPrefix: 'app:'
})

Only startup nodes are needed — the rest of the cluster is discovered. Node-level options (password, tls, connectTimeout, keyPrefix…) and cluster-level ones (maxRedirections, scaleReads, slotsRefreshTimeout, useSRVRecords, shardedSubscribers…) are sorted for you; the retry backoff you configure applies to the cluster client, to each node connection and to the subscribers built from them (see Reconnection). The split is checked against ioredis's own ClusterOptions by a test, because an option filed on the wrong side is not an error — it is silently ignored.

What changes once keys live on different nodes:

  • Multi-key commands need one slot. mget, mset, del with several keys, multi() batches — all of them fail with CROSSSLOT unless the keys hash together. Force that with a hash tag: {user:1}:name and {user:1}:role share a slot, so a command can span them.
  • getAllStream and deleteByPattern walk every master and merge the results, because a cluster has no keyspace-wide SCAN. Deletion issues one UNLINK per key for the same slot reason.
  • Database 0 only — asking for another one is rejected at construction rather than silently reading from the wrong place.
  • Keyspace events are node-local. Unlike publish, which the cluster bus spreads everywhere, each node emits keyspace notifications for its own slots and never forwards them. subscribeToKeyEvents therefore opens one subscriber per master and keeps the set reconciled with the live topology — resharding is followed by event, and promotions (which the driver announces to no one) by a periodic resync that also releases subscribers of departed masters. The reconciliation is per channel, not per node: a master whose subscriber had to be rebuilt, or that refused one SUBSCRIBE while it was LOADING, is caught up on exactly what it is missing. Unsubscribing the last of these channels releases the per-node connections and the topology watch. Its probe requires every master to be configured — one silent shard is one third of your expirations gone. Mind the alias: A in notify-keyspace-events deliberately excludes the n (new-key) and m (key-miss) classes, and the probe knows it — the canonical "AKE" is not enough for subscribeToKeyEvents('new', …).
  • Everything single-key is unchanged: locks (the Lua scripts declare their key, so they route), cache-aside, counters, sorted sets, streams.

The cluster suite runs against three real masters:

docker compose -f docker-compose.cluster.yml up -d --wait
npm run test:cluster

High availability (Sentinel)

| Option | Type | Default | Description | | --- | --- | --- | --- | | sentinels | Array<{host, port}> | — | Sentinel nodes; providing this enables sentinel mode | | name | string | — | Master group name to resolve (e.g. 'mymaster') | | sentinelPassword | string | — | Password for the sentinel nodes themselves | | role | 'master' \| 'slave' | 'master' | Which role to connect to |

const redis = new RedisClient({
  sentinels: [{ host: 'sentinel-1', port: 26379 }, { host: 'sentinel-2', port: 26379 }],
  name: 'mymaster'
})

Failover is handled by ioredis: on READONLY replies the client reconnects to the new master and resends the failed command.

Reconnection

| Option | Type | Default | Description | | --- | --- | --- | --- | | maxRetryAttempts | number | Infinity | Attempts before the driver gives up. 0 means never retry | | baseRetryDelay | number | 1000 | Base for the exponential backoff (ms) | | maxRetryDelay | number | 30000 | Backoff cap (ms) |

Reconnection is handled entirely by the ioredis driver — there is exactly one reconnection loop. When the attempts are exhausted the client emits end and releases its resources; a later connect() starts a fresh cycle.

In a cluster the same backoff governs three things: the Cluster client, each node connection, and the subscriber connections built from them. ioredis leaves per-node reconnection off by default (a closed node connection is never retried; the pool waits for a MOVED to rebuild it), which would quietly exempt exactly the sockets this library owns — a keyspace-event subscriber is a duplicate() of a node connection, so one blip used to take that shard's events down for good. Nodes that genuinely leave the cluster are still disconnected by the driver's own pool reset, so nothing retries against an address that is gone.

Health check

| Option | Type | Default | Description | | --- | --- | --- | --- | | healthCheckInterval | number | 5000 | Minimum interval between real PINGs issued by checkHealth() (ms) | | healthCheckTimeout | number | 1000 | Timeout for the checkHealth() PING (ms) |

checkHealth() is an explicit probe for readiness endpoints. Regular commands never pay for a PING: their gate is a local check of the driver's own status.

Advanced (ioredis passthrough)

Every option not listed as this library's own is forwarded to ioredis untouchedtls, connectTimeout, keepAlive, family, path, natMap, enableOfflineQueue and anything the driver adds later. These are the ones with a default worth knowing:

| Option | Type | Default | Description | | --- | --- | --- | --- | | commandTimeout | number | — | Per-command timeout (ms). No default on purpose: it would break blocking reads | | maxRetriesPerRequest | number \| null | null | Retries per command | | enableReadyCheck | boolean | true | Wait for the server to be truly ready | | autoResubscribe | boolean | true | Resubscribe channels after reconnection | | autoResendUnfulfilledCommands | boolean | true | Resend in-flight commands after reconnection | | lazyConnect | boolean | true | Do not connect on instantiation | | enableOfflineQueue | boolean | true | Queue commands issued while the connection is down. Set false to make the driver reject them instead of holding them until it recovers | | logger | object | built-in | See Logging |

A few options are this library's to set and are refused with INVALID_OPTION rather than silently disabling something:

| Option | Why | | --- | --- | | retryStrategy, reconnectOnError, clusterRetryStrategy, clusterNodeRetryStrategy | Reconnection is the driver's job through those hooks; replacing them disables the documented retry policy. Use maxRetryAttempts, baseRetryDelay and maxRetryDelay | | replyMapping | This library parses replies in their RESP2-compatible shape (CONFIG GET as a flat array, WITHSCORES as alternating member/score). ioredis 6 speaks RESP3 and keeps that shape through its default mapping; the 'resp3' mapping changes it underneath | | redisOptions | Built here from the flat option list in cluster mode (see Cluster) — pass node options at the top level |

Malformed options fail at construction rather than at the first command under load:

new RedisClient({ healthCheckTimeout: 'soon' })
// RedisClientError: healthCheckTimeout must be a finite non-negative number (got "soon"). [INVALID_OPTION]

Infinity is only accepted for maxRetryAttempts, where it means "never give up". Everywhere else it is a delay or a timeout that ends up in a timer, and Node clamps an out-of-range delay to 1ms — so commandTimeout: Infinity written for "no timeout" would fail every command after a millisecond. Omit the option to leave it unbounded.

TLS and managed providers

Managed Redis (Upstash, Redis Cloud, Azure Cache, ElastiCache with encryption in transit) requires TLS. Pass tls and it goes straight to the driver:

const redis = new RedisClient({
  host: 'my-db.upstash.io',
  port: 6380,
  password: process.env.REDIS_PASSWORD,
  tls: {} // an empty object is enough for a provider with a public certificate
})

For a private CA, tls takes the usual Node TLS options (ca, cert, key, servername).

Events

| Event | Payload | Fires when | | --- | --- | --- | | ready | — | The connection is established and the server accepted commands (also after every successful reconnection) | | close | — | The connection dropped | | reconnecting | delay?: number | The driver scheduled a reconnection attempt | | end | — | The client is done: after disconnect(), or when maxRetryAttempts is exhausted | | connectionError | Error | The driver reported a connection-level error (never emitted as 'error', so an unsubscribed process is never crashed) | | message | channel, message | A subscribed channel received a message | | pmessage | pattern, channel, message | A pattern subscription received a message |

redis.once('ready', () => console.log('connected'))
redis.on('connectionError', (err) => metrics.increment('redis.errors'))

Error handling

All library errors are RedisClientError instances carrying operation and a stable code — branch on the code, never on message text:

| Code | Meaning | | --- | --- | | REDIS_UNAVAILABLE | The command was rejected because the connection is not ready — or disconnect() is in progress. Nothing was sent | | UNSUPPORTED_OPERATION | The method cannot work safely on the shared connection (watch/unwatch) | | LOCK_NOT_ACQUIRED | acquireLock/withLock could not obtain the lock within the configured retries. Carries lockName, so a caller holding locks inside a getOrSet producer can tell whose failure it is | | INVALID_ARGUMENT | A required argument is missing or malformed (e.g. xtrim without a count) | | INVALID_OPTION | A constructor option is malformed or is one the library manages | | KEYSPACE_NOTIFICATIONS_DISABLED | The server is not configured to emit the requested key event | | OPERATION_TIMEOUT | A deadlined internal call (the keyspace CONFIG probe, shutdown QUITs) got no answer in time | | REDIS_CLIENT_ERROR | Generic library error |

import { RedisClientError } from '@pinceladasdaweb/redis'

try {
  await redis.set('key', 'value')
} catch (err) {
  if (err instanceof RedisClientError && err.code === 'REDIS_UNAVAILABLE') {
    // Redis is down and reconnecting — degrade gracefully
  } else {
    throw err
  }
}

Command errors coming from the server (e.g. WRONGTYPE) are ioredis errors and propagate as-is.

Logging

The library logs through whatever you inject — any object with error, warn, info (and optionally debug) methods works, so a pino/winston/bunyan instance plugs in directly:

import pino from 'pino'

const redis = new RedisClient({ host: '127.0.0.1', port: 6379, logger: pino() })

Without injection you get a dependency-free leveled console logger (default level info, configurable via LOG_LEVEL). It is exported for reuse:

import { createLogger } from '@pinceladasdaweb/redis'

const logger = createLogger('debug')

Hot paths log at debug level only.

Caching (cache-aside)

getOrSet/getOrSetJson implement the read-through pattern: return the cached value, or run the producer, store its result with the ttl and return it.

const user = await redis.getOrSetJson(`user:${id}`, 300, () => db.loadUser(id))

Under load, an expired hot key means N concurrent misses running N producers (the dogpile/stampede effect). Enable the built-in protection and they collapse into one producer call — the winner fills the cache while the others wait on a lock and re-read:

const report = await redis.getOrSetJson('report:daily', 3600, buildExpensiveReport, { lock: true })
// lock accepts LockOptions too: { lock: { ttl: 30000, retries: 200 } }

Guarantees worth knowing:

  • The cache lock auto-extends by default, so a producer slower than the lock ttl does not reopen the stampede.
  • A cache call never surfaces lock errors: a waiter that exhausts its retry budget re-reads the cache (the winner has usually filled it by then) and, as a last resort, runs the producer without protection.
  • The producer's value must be cacheable — getOrSet accepts strings and numbers, getOrSetJson anything JSON-serializable. Anything else (including undefined) rejects with INVALID_ARGUMENT without writing to the cache.

To invalidate, delete by pattern — SCAN + UNLINK in batches (non-blocking, never KEYS), confined to your keyPrefix:

const removed = await redis.deleteByPattern('user:*')
// The pattern is required: deleteByPattern('*') wipes the whole prefixed keyspace, so say it explicitly.

Transactions and dedicated connections

multi() returns an ioredis pipeline for atomic batches on the shared connection:

const results = await (await redis.multi()).incr('counter').expire('counter', 60).exec()

WATCH state is per-connection, so watch()/unwatch() on the shared connection would be silently broken under concurrency — they reject with UNSUPPORTED_OPERATION. For real optimistic locking, use withDedicatedConnection(): it hands fn a short-lived isolated client (full configuration inherited) and always releases it:

const committed = await redis.withDedicatedConnection(async (conn) => {
  await conn.watch('balance')
  const balance = Number(await conn.get('balance'))

  return conn.multi().set('balance', String(balance - 100)).exec()
  // resolves null if 'balance' changed since watch() — retry your logic
})

Pub/Sub

A Redis connection in subscriber mode cannot run regular commands, so subscriptions live on a dedicated connection managed by the library (created on the first subscribe, released on disconnect()). Subscriptions survive reconnections automatically — the integration suite kills the subscriber server-side and proves delivery resumes.

await redis.subscribe('news', (message, channel) => {
  console.log(`${channel}: ${message}`)
})

await redis.psubscribe('logs.*', (message, channel, pattern) => {
  console.log(`${pattern} matched ${channel}`)
})

await redis.publish('news', 'hello')
await redis.publishJson('logs.app', { level: 'info' })

await redis.unsubscribe('news')
await redis.punsubscribe('logs.*')

Messages also arrive as facade events (message, pmessage) if you prefer a single listener. Handler rejections are caught and logged — they never crash the process. Note: channels are not keys, so keyPrefix does not apply to them.

Pub/Sub has no delivery receipt. publish returns how many subscribers received the message, and zero is not an error — it means nobody was listening and the message is gone. If that matters, check the count:

const receivers = await redis.publish('orders:new', payload)
if (receivers === 0) {
  logger.warn('no consumer online; event dropped')
}

Reconnection is fast, not transparent. Subscriptions come back on their own, but anything published while the subscriber was away is gone — Redis pub/sub queues nothing. Measured here by publishing a sequence every 5ms and killing the subscriber server-side:

| Run | Published | Received | Lost | |---|---|---|---| | 1 | 419 | 400 | 19 (~95ms) | | 2 | 426 | 406 | 20 (~100ms) | | 3 | 424 | 404 | 20 (~100ms) |

The window tracks your reconnect backoff (baseRetryDelay/maxRetryDelay), so you can shrink it — never close it. If losing those messages is unacceptable, pub/sub is the wrong tool: use Streams with a consumer group, where entries wait in the pending list until acknowledged.

Keyspace events

Redis only emits keyspace events if the server was configured to, and subscribing to a channel that will never speak looks exactly like a subscription that works. subscribeToKeyEvents probes the configuration first and tells you what to enable:

await redis.subscribeToKeyEvents('expired', (key) => {
  console.log(`${key} expired`)
})
// RedisClientError: Keyspace notifications are not enabled for 'expired':
// notify-keyspace-events is "", missing "Ex". Enable it with
// CONFIG SET notify-keyspace-events "Ex".   [KEYSPACE_NOTIFICATIONS_DISABLED]

Read the current flags with keyspaceNotifications(). Managed providers often block CONFIG; when the probe cannot run, the subscription proceeds with a warning rather than being refused.

In a cluster every master is configured on its own and emits only its own slots' events, so keyspaceNotifications() answers with the flags every master agrees on — and an empty string the moment they differ, logging which node reported what. Sampling one master would report a healthy value while another shard sits silent, which is the failure the probe exists to catch. keyspaceNotificationsByNode() gives the per-master breakdown as [{ node, flags }] (node is null outside a cluster).

Each channel/pattern holds one handler — subscribing again replaces it (last one wins). Use the message/pmessage events when you need fan-out to multiple listeners. If the subscriber connection permanently gives up (finite maxRetryAttempts exhausted), a warning is logged and the next subscribe() starts a fresh connection — resubscribe to restore delivery.

Distributed locking

Single-instance locking: acquisition via SET NX PX, release and extension via Lua scripts that check the holder token — you can never release or extend a lock you no longer hold. This is a best-effort mutex against one Redis instance, not Redlock: no multi-node quorum claims.

// Managed: acquire, run, always release
await redis.withLock('reports:daily', { ttl: 60000, retries: 5, retryDelay: 200 }, async () => {
  await generateDailyReport()
})

// Manual control
const lock = await redis.acquireLock('reports:daily', { ttl: 60000 })
try {
  await generateDailyReport()
  await lock.extend(60000) // still holding? reset the ttl
} finally {
  await lock.release() // false if the lock had already expired
}

Failing to acquire rejects with code: 'LOCK_NOT_ACQUIRED'. Locks are stored as lock:<name> (your keyPrefix applies). Scripts are cached and transparently reloaded after a server restart (NOSCRIPT).

Two options worth knowing:

  • retryJitter adds a random extra delay (0..n ms) per acquisition attempt — under contention, fixed delays make every waiter retry in lockstep.
  • autoExtend: true (in withLock only) starts a watchdog that keeps extending the lock at half-ttl intervals while your callback runs — for critical sections that may outlive the ttl. If the lock is definitively lost, the watchdog stops and logs a warning.
await redis.withLock('report:build', { ttl: 30000, autoExtend: true, retries: 10, retryJitter: 100 }, async () => {
  await possiblyVerySlowJob()
})

Without autoExtend, keep the critical section shorter than the ttl — the ttl is the safety net that prevents dead holders from blocking everyone forever.

Lua scripts

Anything that has to be atomic across several keys or several steps — a compare-and-set, a rate-limiter window, a fenced state machine — belongs in Lua, because Redis runs a script to completion without interleaving.

executeCommand('eval', …) works and sends the script body on every call. That is fine occasionally and wrong on a hot path. Register it instead and the driver sends the SHA (~40 bytes), reloads it by itself on NOSCRIPT after a restart or failover, and reinstalls it on the new connection after a reconnection cycle:

redis.defineScript('fencedSet', {
  numberOfKeys: 1,
  lua: `
    local current = tonumber(redis.call("hget", KEYS[1], "generation")) or 0
    if tonumber(ARGV[1]) < current then return {0, current} end
    redis.call("hset", KEYS[1], "generation", ARGV[1], "state", ARGV[2])
    return {1, tonumber(ARGV[1])}
  `
})

await redis.runScript('fencedSet', ['breaker:api'], [generation, 'open'])
// [1, 3] — applied.  [0, 4] — refused: a newer generation already won.

Registration is lazy, so defineScript needs no connection and can run at module load.

Keys and arguments travel as two arrays. The driver splits a flat list positionally at numberOfKeys and cannot tell a misplaced key from a deliberate one: get the boundary wrong and the script reads a key nobody named — and in a cluster, routes to the node that key hashes to. Because the count is declared at registration, this library checks it on every call and rejects with INVALID_ARGUMENT instead of letting it through.

Three things follow from KEYS being explicit:

  • Your keyPrefix applies to them, exactly as it does to any other key.
  • Cluster routing works — the script runs on the node owning its keys. Keys in different slots need a hash tag, same as mget.
  • readOnly: true marks a script as a reader, so scaleReads may send it to a replica.

Server-side errors (a Lua syntax error, WRONGTYPE, a wrong numberOfKeys for what the script actually touches) propagate as the driver's own error after being logged.

Sorted sets and rankings

Sorted sets keep members ordered by score — the backbone of leaderboards, priority queues and score-based windows. Two conveniences over the raw protocol:

  • Scores come back as numbers. Redis sends them as strings, infinities included, and Number('inf') is NaN. zscore, zincrby and every withScores result parse them for you (Infinity and -Infinity survive the round-trip). A member that is not in the set reads as null, never NaN.
  • withScores returns pairs, not the flat [member, score, member, score] array that is so easy to mis-index.
await redis.zadd('leaderboard', { ada: 120, alan: 95, grace: 180 })
await redis.zincrby('leaderboard', 45, 'ada')   // → 165

await redis.zrevrange('leaderboard', 0, 2, { withScores: true })
// → [{ member: 'grace', score: 180 }, { member: 'ada', score: 165 }, { member: 'alan', score: 95 }]

await redis.zrevrank('leaderboard', 'ada')      // → 1 (zero-based)
await redis.zrangebyscore('leaderboard', 100, '+inf')
await redis.zremrangebyrank('leaderboard', 0, -101)   // keep the top 100

zadd also accepts raw arguments, so flags stay available: zadd(key, 'NX', 'CH', 50, 'member'). Pagination by score uses zrange with byScore:

await redis.zrange('leaderboard', '+inf', '-inf', {
  byScore: true, rev: true, limit: { offset: 20, count: 10 }, withScores: true
})

Popping follows the spop convention — without a count you get a single { member, score } (or null), with one you get an array. That makes zpopmin a natural priority queue: example 17.

Streams

All stream commands are available (xadd, xread, xreadgroup, xgroup, xlen, xinfo, xrange, xrevrange, xdel, xtrim, xpending, xclaim). Two behaviors worth knowing:

  • Blocking reads run on a dedicated connection. xread/xreadgroup with block (including block: 0, which blocks forever) never stall other commands.

  • disconnect() cancels them. A read still waiting when you shut down rejects with REDIS_UNAVAILABLE and its connection is reclaimed, so a consumer loop can exit instead of hanging the process:

    while (running) {
      try {
        const entries = await redis.xreadgroup('workers', 'worker-1', { block: 0 }, ['events', '>'])
        // ...
      } catch (err) {
        if (err.code === 'REDIS_UNAVAILABLE') break // shutting down
        throw err
      }
    }
  • xgroup respects each subcommand's arityCREATE (with optional MKSTREAM), DESTROY, SETID, CREATECONSUMER, DELCONSUMER.

  • keyPrefix is honored everywhere, xgroup and xinfo included. Their key sits after a subcommand, which the driver only learned to recognize in ioredis 6; before that this library prefixed those two by hand, and the integration suite proves the round trip either way.

Entries stay in the group's pending list until acknowledged, which is what makes recovery possible: xack settles them, and xautoclaim sweeps up whatever a dead consumer left behind, returning named fields instead of the positional reply:

const { cursor, entries, deleted } = await redis.xautoclaim('events', 'workers', 'worker-2', 60000)
// entries: deliveries idle for over a minute, now owned by worker-2
// cursor: '0-0' once the pending list has been covered
// deleted: entries that were pending but whose data is GONE — see below

deleted is lost work, not bookkeeping. XDEL removes an entry from the stream but leaves it in the group's pending list, where no consumer can ever process it. xautoclaim is what clears those ghosts out, and it reports them here. Ignore the field and the loss is silent.

Retry budgets: what delivery_count actually counts

xpending with a range returns [id, consumer, idleMs, deliveryCount] per entry. Measured against a real Redis 7.4 rather than assumed:

| Action | delivery_count | |---|---| | First delivery via xreadgroup(..., ['s', '>']) | 1 — not 0 | | Re-reading your own pending list with '0' | +1 | | xclaim / xautoclaim | +1 | | The same two with justId: true | unchanged | | A server restart | preserved |

Two of these bite:

  • The recovery read costs budget. Re-reading your own pending entries with id '0' — what every consumer does on startup — counts as a delivery. A crash loop burns through a retry budget without a single new delivery. Use justId: true to inspect a pending entry without spending.
  • After a restart, the two inputs disagree. delivery_count survives; the idle clock resets to zero. A policy written purely against minIdleTime silently forgets everything the moment the server bounces, so use the count for "how many times has this failed" and idle only for "is the owner gone".

Consumer groups, acknowledgements and recovery of stalled entries are covered end to end in example 12.

await redis.xadd('events', '*', 'type', 'signup')
await redis.xgroup('CREATE', 'events', 'workers', '$', true) // MKSTREAM

const entries = await redis.xreadgroup('workers', 'worker-1', { count: 10, block: 5000 }, ['events', '>'])

Keyspace scan

getAllStream(pattern) returns [{ key: value }, ...] for every string key matching the pattern within your keyPrefix (raw SCAN ignores ioredis prefixes; this method compensates). Non-string keys are skipped — and only those: any other per-key failure (a MOVED during a cluster reshard, say) fails the walk loudly, because a truncated result that looks complete is worse than an error. Reads are pipelined per SCAN batch, and returned keys come unprefixed:

const redis = new RedisClient({ host, port, keyPrefix: 'myapp:' })
await redis.getAllStream('user:*') // [{ 'user:1': '...' }, { 'user:2': '...' }]

Full method reference

Connection: connect(), disconnect(), checkHealth(), withDedicatedConnection(fn) Strings: get, set, setex, incr, decr, mset, mget JSON: setJson, getJson, setexJson Cache: getOrSet, getOrSetJson, deleteByPattern Hashes: hset (pairs or object), hget, hgetall, hmset (delegates to HSET; returns the number of new fields), hmget, hincrby, hexists, hdel Lists: lpush, rpop, lrange, llen, lrem, lpushx, rpushx Sets: sadd, smembers, sismember, scard, spop (single member without count, array with it), srem Sorted sets: zadd, zscore, zincrby, zcard, zcount, zrank, zrevrank, zrem, zrange, zrevrange, zrangebyscore, zremrangebyrank, zremrangebyscore, zpopmin, zpopmax — see Sorted sets Keys: del, exists, type, rename, renamenx, persist, expire, ttl, sort Transactions: multi() (watch/unwatch reject — see above) Pub/Sub: publish, publishJson, subscribe, unsubscribe, psubscribe, punsubscribe, subscribeToKeyEvents, keyspaceNotifications, keyspaceNotificationsByNode Locking: acquireLock, withLock Lua: defineScript, runScript — see Lua scripts Streams: see Streams Scan: getAllStream(pattern)

Anything not wrapped is reachable through redis.client (the raw ioredis instance) or executeCommand(command, ...args).

Notes on semantics

  • Values are sent as-is: no implicit JSON serialization anywhere (mset included). Use the *Json helpers.
  • sort()'s by and get patterns are sent verbatim: unlike keys, the driver never rewrites them, so include your keyPrefix yourself when using them.
  • getJson returns null for missing keys and throws SyntaxError on non-JSON payloads.
  • getOrSet/getOrSetJson never surface their own lock's errors — an exhausted budget falls back to re-read, then unprotected produce. A LOCK_NOT_ACQUIRED thrown by a lock the producer holds is the producer's error and propagates like any other (the lockName field is how they are told apart).
  • A command issued while disconnect() is running rejects with REDIS_UNAVAILABLE, same as a dead connection: shutdown refuses to create new connections it would have to hunt down.
  • A command issued while disconnected rejects with REDIS_UNAVAILABLE — it is not queued (the tiny race window that slips into the driver's offline queue is resent on reconnection; bound it with commandTimeout if needed).
  • xpending() answers two different questions in two different shapes: the group summary with no options, the pending entries with start, end and count. A partial range rejects with INVALID_ARGUMENT instead of silently answering the other one.
  • Blocking reads (xread/xreadgroup with block) run on a dedicated connection so they never stall the shared one, and that connection is pooled between reads — a consumer loop does not pay a handshake per iteration. disconnect() closes the pool along with everything else.
  • disconnect() always finishes. Every quit() on the way out has a 2-second deadline, after which the socket is forced closed: the driver parks QUIT behind whatever is already in its offline queue, and on a connection that is still retrying that reply may never come.

Development

npm run hooks              # once per clone: enable the repo's git hooks (lint, commitlint)
docker compose up -d       # pinned redis:7.4-alpine
npm test                   # unit tests (no server required, under a second)
npm run test:integration   # full suite against the real Redis, including CLIENT KILL recovery
npm run examples           # all 17 examples against the real Redis
npm run test:mutation      # the mutation gate: do the tests actually assert?
npm run check:types        # tsc --strict on index.d.ts
npm run verify:package     # pack, install in a clean project, import via ESM and CJS

The published package declares zero lifecycle scripts, so it installs without any allow-scripts approval friction.

The integration suite is destructive (it kills connections server-side) — never point it at a shared Redis.

Contributing

Reliability claims here are only as good as the suite that enforces them, so every change needs a test that fails without it. CONTRIBUTING.md covers the invariants worth knowing before changing code — where reconnection lives, why zero is a legitimate option value, how timing is tested — plus the commands to run.

Found a vulnerability? Please follow SECURITY.md instead of opening a public issue.

Author

Pedro Rogério — GitHub

License

MIT