mrkeyoor.com_
Thu 06 Aug 07:43 UTC
npmDataupdated 06 Aug 2026

ioredis

ioredis is a Redis client for Node.js written in TypeScript. Every Redis command is a method on the client, so redis.set('k', 'v', 'EX', 10) maps straight onto the CLI form, and the last argument decides whether you get a promise or a Node-style callback. On top of the command surface it handles the operational parts people otherwise write themselves: automatic reconnection with exponential backoff, an offline queue that holds commands issued before the connection is ready, Cluster and Sentinel support with slot mapping and failover handling, pipelining and MULTI transactions, a stream interface over SCAN, transparent key prefixing, and Lua scripts registered as first-class commands that use EVALSHA when they can. It has been the default Redis client in the Node world for years, and BullMQ, Bee-Queue, and many session and rate-limit libraries take an ioredis instance directly.

Verdict

Still the most capable Redis client in Node for Cluster and Sentinel work, and the one your queue library probably expects. For a greenfield service its own README points you at node-redis, and that advice is worth taking seriously.

API stability4/5The command API has been steady since v4 and v6 broke only two things, requiring Node 20 and defaulting to RESP3 with protocol: 2 as the escape hatch; the caveat is that default import of Redis is documented as deprecated in the next major, so import { Redis } is what new code should use
Docs4/5One very long README covering pipelining, Lua, Cluster, Sentinel, TLS, autopipelining, and diagnostics channels with runnable examples, plus generated TypeDoc for options; finding a specific option means scrolling, and the upgrade guides live on a wiki rather than in the repo
Maintenance3/5Under the Redis org with real releases including 6.0.0 on 2026-07-31 and security fixes such as validating MOVED slots against prototype pollution, but the README states maintenance is best-effort and points new users at node-redis, and roughly 191 issues stay open
Ecosystem5/5Around 26M downloads a week and the client that BullMQ, connect-redis, rate-limiter-flexible, and most Node Redis tutorials assume; almost any Redis question about Node has an ioredis-shaped answer already written

Use it if

  • You run Redis Cluster or Sentinel: slot mapping, MOVED and ASK redirection, read-write splitting via scaleReads, and NAT mapping are all built in rather than bolted on
  • You depend on a library that expects an ioredis instance, which covers BullMQ, connect-redis, rate-limiter-flexible, and a long tail of others
  • You want reconnect behavior you can control, since retryStrategy, reconnectOnError, maxRetriesPerRequest, and the offline queue are all configurable per instance
  • You want Lua scripts to look like ordinary commands: defineCommand registers a script and handles the EVALSHA fallback for you
  • You need to iterate a large keyspace safely, where scanStream, hscanStream, and zscanStream give you a pausable Node readable stream over SCAN
Skip it if

Setup reality

npm install ioredis is all it takes, TypeScript declarations are in the package, and new Redis() connects to 127.0.0.1:6379 immediately with no explicit connect call. That last part is the first surprise: the constructor opens a socket, so creating a client at module scope in a serverless function or a test file starts a connection you may not want, and lazyConnect: true is the fix. The second surprise is error handling. ioredis emits error events silently unless something is listening, so a client with no error listener looks fine while every command times out. Attach one before you do anything else. v6 changed two defaults worth checking on upgrade: Node 20 is now the floor, and RESP3 is on by default, with protocol: 2 available if you need the old wire protocol. RESP3 keeps RESP2-shaped replies unless you also set replyMapping: 'resp3', at which point map replies become plain objects and doubles become numbers, which will break code that indexed into flat arrays. Also note keyPrefix does not apply to KEYS or SCAN patterns, or to key names that come back inside replies.

Patterns

Create a client you can actually run in productionconnect-with-options

import { Redis } from 'ioredis'

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  username: 'default',
  password: process.env.REDIS_PASSWORD,
  db: 0,
  lazyConnect: true,          // do not dial until the first command
  connectTimeout: 10_000,
  keepAlive: 30_000,
  retryStrategy: (times) => Math.min(times * 200, 5_000),
})

await redis.connect()

// or from a URL
const other = new Redis('rediss://default:pw@cache.example.com:6380/2')

Without lazyConnect the constructor opens a socket right away, which is wrong in tests and in serverless handlers. Note that import { Redis } is the form to use; the default export still works but is documented as deprecated in the next major.

Listen for errors or lose themhandle-connection-events

redis.on('error', (err) => console.error('redis error', err))
redis.on('connect', () => console.info('socket open'))
redis.on('ready', () => console.info('accepting commands'))
redis.on('reconnecting', (delayMs: number) => console.warn('retry in', delayMs))
redis.on('end', () => console.warn('gave up reconnecting'))

console.log(redis.status)  // 'wait' | 'connecting' | 'connect' | 'ready' | 'close' | 'reconnecting' | 'end'

ioredis only emits error events when there is at least one listener, so a client with none looks healthy while every command silently queues and then times out. This listener is the single most valuable line in the file.

Send a batch in one round trippipeline-commands

const results = await redis
  .pipeline()
  .set('user:1:name', 'Ada')
  .incr('user:1:visits')
  .expire('user:1:name', 3600)
  .exec()

// results === [[null, 'OK'], [null, 4], [null, 1]]
for (const [err, value] of results ?? []) {
  if (err) throw err
}

exec() resolves even when individual commands fail: the outer error is null and each entry is a [error, result] tuple, so a pipeline that silently swallows failures is the default unless you loop like this.

Run commands atomically with MULTItransaction-multi

const results = await redis
  .multi()
  .decrby('stock:42', 1)
  .sadd('orders', 'order:99')
  .exec()

// EXECABORT: nothing ran
const bad = await redis.multi().set('foo').set('foo', 'v').exec()
// bad rejects with a ReplyError carrying err.previousErrors

multi() is a pipeline that wraps the commands in MULTI/EXEC, so it is atomic on the server but still one round trip. A syntax error in any queued command aborts the whole transaction and rejects with EXECABORT rather than returning per-command tuples.

Register a Lua script as a commandlua-script

redis.defineCommand('rateLimit', {
  numberOfKeys: 1,
  lua: `
    local current = redis.call('INCR', KEYS[1])
    if current == 1 then
      redis.call('PEXPIRE', KEYS[1], ARGV[1])
    end
    return current`,
})

const hits = await (redis as any).rateLimit('rl:user:7', 60_000)
if (hits > 100) throw new Error('rate limited')

ioredis caches the script and uses EVALSHA, falling back to EVAL after a NOSCRIPT. The first numberOfKeys arguments become KEYS and the rest become ARGV; get that count wrong and Cluster routes the script to the wrong node.

Iterate keys without blocking the serverscan-keyspace

const stream = redis.scanStream({ match: 'session:*', type: 'string', count: 200 })

stream.on('data', (keys: string[]) => {
  if (keys.length === 0) return
  stream.pause()
  redis.unlink(...keys).then(() => stream.resume())
})
stream.on('end', () => console.log('done'))

Never use KEYS on a live server. SCAN can hand you the same key twice and can return empty batches, so downstream work has to be idempotent. pause/resume is the only backpressure you get.

Subscribe on a dedicated connectionpubsub

const sub = new Redis(process.env.REDIS_URL!)
const pub = new Redis(process.env.REDIS_URL!)

await sub.subscribe('news', 'alerts')
sub.on('message', (channel, message) => {
  console.log(channel, message)
})

// pattern form
await sub.psubscribe('user:*:events')
sub.on('pmessage', (pattern, channel, message) => { /* ... */ })

await pub.publish('news', JSON.stringify({ id: 1 }))

Once a connection is subscribed, Redis rejects ordinary commands on it, so publishing and subscribing always need two clients. On reconnect ioredis resubscribes for you unless you set autoResubscribe: false, but messages sent while disconnected are gone.

Connect to a Redis Clustercluster-client

import { Cluster } from 'ioredis'

const cluster = new Cluster(
  [{ host: 'node-a', port: 6379 }, { host: 'node-b', port: 6379 }],
  {
    scaleReads: 'slave',
    maxRedirections: 16,
    retryDelayOnFailover: 100,
    slotsRefreshInterval: 30_000,
    redisOptions: { password: process.env.REDIS_PASSWORD },
  },
)

// keys in one command must share a slot; hash tags force that
await cluster.mget('{user:7}:name', '{user:7}:email')

The startup node list only needs to reach one live node; the rest is discovered. Passwords go in redisOptions, not at the top level. Multi-key commands still have to land in one slot, which is what the {braces} hash tag is for.

Namespace keys per clientkey-prefix

const tenant = new Redis({ keyPrefix: 'tenant:42:' })
await tenant.set('cart', '[]')          // SET tenant:42:cart []

// the prefix is NOT applied here:
const stream = tenant.scanStream({ match: 'tenant:42:*' })

keyPrefix rewrites key arguments but not pattern arguments, so KEYS and SCAN patterns must include the prefix yourself. It also does not rewrite key names that appear inside replies, which bites when you SCAN and then feed results back in.

Batch same-tick commands automaticallyauto-pipelining

const redis = new Redis({ enableAutoPipelining: true })

// these three are issued in one event loop turn and go out together
const [a, b, c] = await Promise.all([
  redis.get('a'),
  redis.get('b'),
  redis.get('c'),
])

Commands issued during one event loop iteration are flushed as a single pipeline with no code changes, which the README measures at 35 to 50 percent better throughput. In Cluster mode one pipeline is built per node, and the same-slot rule for individual commands still applies.

Opt into RESP3 reply shapesresp3-replies

const legacy = new Redis()                                  // protocol 3, legacy shapes
await legacy.config('GET', 'maxmemory')                     // ['maxmemory', '0']

const modern = new Redis({ protocol: 3, replyMapping: 'resp3' })
await modern.config('GET', 'maxmemory')                     // { maxmemory: '0' }
await modern.zscore('scores', 'ada')                        // 1.5 as a number

const old = new Redis({ protocol: 2 })                      // v5 wire protocol

v6 speaks RESP3 by default but keeps RESP2-shaped replies, so upgrading does not change your code until you also set replyMapping: 'resp3'. Combining replyMapping: 'resp3' with protocol: 2 throws at construction.

Close connections without dropping in-flight workgraceful-shutdown

process.on('SIGTERM', async () => {
  await redis.quit()        // finishes queued commands, then closes
  process.exit(0)
})

// hard stop, drops anything pending
redis.disconnect()

// long-blocking consumers (BullMQ workers) need this
const worker = new Redis({ maxRetriesPerRequest: null })

quit() sends QUIT and lets queued commands finish; disconnect() tears the socket down immediately. Both stop the auto-reconnect loop. maxRetriesPerRequest defaults to 20, which makes BRPOP-style commands fail during a reconnect, so BullMQ requires null.

Alternatives

PackageRegistryPick it when
redisnpmNew projects, per the ioredis README, and anything that needs Redis 8 or Redis Stack commands with first-class support
@upstash/redisnpmYou are on serverless or edge runtimes where a persistent TCP socket is not an option and HTTP-based access fits better
iovalkeynpmYou have moved to Valkey and want a fork that tracks it while keeping the ioredis API you already wrote against