@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
Maintainers
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 (
baseRetryDelay→maxRetryDelay), configurable attempt limit, automatic recovery after server-side kills and failovers (READONLYreplies 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:
RedisClientis anEventEmitter—ready,close,reconnecting,endandconnectionErrortell you exactly what the connection is doing. - Dedicated connections when they matter: blocking stream reads (
BLOCK) never stall the shared connection, andwithDedicatedConnection()gives you isolatedWATCH/MULTI/EXECoptimistic 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 KILLtest. - Distributed locking (single instance):
withLock()/acquireLock()withSET NX PXacquisition 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 onNOSCRIPTand 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/setexJsonwith explicit serialization — never magic. - Cache-aside with stampede protection:
getOrSet/getOrSetJsonreturn the cached value or produce-and-store it — and with{ lock: true }, concurrent misses collapse into a single producer call. - Bulk deletion done right:
deleteByPatternusesSCAN+UNLINKin batches (non-blocking, prefix-aware) instead ofKEYS. - Sentinel support: pass
sentinels+nameand the client rides ioredis' native high-availability failover. - Prefixed keyspace scan:
getAllStream(pattern)dumps your keys (and only yours —keyPrefixis honored, unlike rawSCAN), skipping non-string types gracefully. - Rankings without the footguns: sorted-set scores return as real numbers (infinities included) and
withScoresgives you{ member, score }pairs instead of a flat array. - TypeScript declarations: hand-maintained
index.d.ts, checked withtsc --strictin CI. - One runtime dependency: ioredis. Nothing else.
Installation
npm install @pinceladasdaweb/redisWorks 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,WITHSCORESranges,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: 2is forwarded to the driver like any other option. redis.clienthands 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,delwith several keys,multi()batches — all of them fail withCROSSSLOTunless the keys hash together. Force that with a hash tag:{user:1}:nameand{user:1}:roleshare a slot, so a command can span them. getAllStreamanddeleteByPatternwalk every master and merge the results, because a cluster has no keyspace-wideSCAN. Deletion issues oneUNLINKper 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.subscribeToKeyEventstherefore 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 oneSUBSCRIBEwhile it wasLOADING, 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:Ainnotify-keyspace-eventsdeliberately excludes then(new-key) andm(key-miss) classes, and the probe knows it — the canonical"AKE"is not enough forsubscribeToKeyEvents('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:clusterHigh 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 untouched — tls, 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 —
getOrSetaccepts strings and numbers,getOrSetJsonanything JSON-serializable. Anything else (includingundefined) rejects withINVALID_ARGUMENTwithout 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:
retryJitteradds a random extra delay (0..n ms) per acquisition attempt — under contention, fixed delays make every waiter retry in lockstep.autoExtend: true(inwithLockonly) 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
keyPrefixapplies 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: truemarks a script as a reader, soscaleReadsmay 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')isNaN.zscore,zincrbyand everywithScoresresult parse them for you (Infinityand-Infinitysurvive the round-trip). A member that is not in the set reads asnull, neverNaN. withScoresreturns 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 100zadd 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/xreadgroupwithblock(includingblock: 0, which blocks forever) never stall other commands.disconnect()cancels them. A read still waiting when you shut down rejects withREDIS_UNAVAILABLEand 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 } }xgrouprespects each subcommand's arity —CREATE(with optionalMKSTREAM),DESTROY,SETID,CREATECONSUMER,DELCONSUMER.keyPrefixis honored everywhere,xgroupandxinfoincluded. 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 belowdeleted 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. UsejustId: trueto inspect a pending entry without spending. - After a restart, the two inputs disagree.
delivery_countsurvives; the idle clock resets to zero. A policy written purely againstminIdleTimesilently 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 (
msetincluded). Use the*Jsonhelpers. sort()'sbyandgetpatterns are sent verbatim: unlike keys, the driver never rewrites them, so include yourkeyPrefixyourself when using them.getJsonreturnsnullfor missing keys and throwsSyntaxErroron non-JSON payloads.getOrSet/getOrSetJsonnever surface their own lock's errors — an exhausted budget falls back to re-read, then unprotected produce. ALOCK_NOT_ACQUIREDthrown by a lock the producer holds is the producer's error and propagates like any other (thelockNamefield is how they are told apart).- A command issued while
disconnect()is running rejects withREDIS_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 withcommandTimeoutif needed). xpending()answers two different questions in two different shapes: the group summary with no options, the pending entries withstart,endandcount. A partial range rejects withINVALID_ARGUMENTinstead of silently answering the other one.- Blocking reads (
xread/xreadgroupwithblock) 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. Everyquit()on the way out has a 2-second deadline, after which the socket is forced closed: the driver parksQUITbehind 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 CJSThe 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
