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.
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.
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
- You are starting a new project: the ioredis README itself says maintenance is best-effort and that node-redis is the recommended client for new work, with better coverage of newer commands and Redis 8 module features
- You want Redis Stack features like search, JSON, or time series with typed helpers, which node-redis covers and ioredis leaves you to send as raw commands
- You are stuck below Node 20: v6 requires it, so you stay on the 5.x branch and its fixes are the only ones you get
- You use BullMQ or any long-blocking consumer and will not read the docs: the default maxRetriesPerRequest of 20 makes blocking commands fail during a reconnect, and BullMQ requires you to set it to null
- You need pub/sub plus regular commands on one connection: a subscribed connection cannot run other commands, so you always end up managing two clients
- You want a small dependency: six transitive packages come along, and the whole command surface is loaded whether you use five commands or two hundred
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.previousErrorsmulti() 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 protocolv6 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
| Package | Registry | Pick it when |
|---|---|---|
| redis | npm | New projects, per the ioredis README, and anything that needs Redis 8 or Redis Stack commands with first-class support |
| @upstash/redis | npm | You are on serverless or edge runtimes where a persistent TCP socket is not an option and HTTP-based access fits better |
| iovalkey | npm | You have moved to Valkey and want a fork that tracks it while keeping the ioredis API you already wrote against |