Redis Cheatsheet 0/15

Backend engineeringRedis 7Data types, patterns, Node.js

Redis
Cheatsheet

Fifteen modules on the in-memory store behind most backend caches, queues and rate limits: every data type, the patterns built from them, and how to run it safely. Every terminal is real redis-cli or Node.js output.

redis-clinode-redis 6
00

Key terms in plain words

New to Redis, or to databases at all? Start here. Every word below shows up later on this page, each compared to a busy post room full of labelled pigeonholes. Underlined words in the modules link back to these cards.

29 terms in 6 groups. Hover an underlined word anywhere on the page for a quick reminder, or click it to jump here.

What Redis is

Redis is a post room. Every pigeonhole has a label, you ask for things by label, and some pigeonholes get cleared out on a timer.

Redis
A database that keeps all its data in memory (RAM) rather than on disk, which makes it very fast. Apps use it for caches, counters, queues and live messages. It runs one command at a time, so one slow command makes everyone else wait.
Key
The name you store a value under and fetch it by. Keys are plain text, and by habit they're built from parts joined with colons so related ones group together.
For example user:42:name holds the name of user 42.
redis-cli and commands
redis-cli is the program you open in a terminal to talk to Redis. Everything you ask is a command: a word like SET or GET followed by a key and maybe a value.
For example SET user:42:name Shree then GET user:42:name
TTL
Time to live: how many seconds a key has left before Redis deletes it on its own. Perfect for login codes and cached copies that go stale. -1 means no expiry; -2 means the key isn't there.
For example SET otp:42 739201 EX 300 keeps the code for five minutes.
SCAN and cursor
KEYS lists every key in one go and blocks Redis while it does. SCAN walks through them in small batches and hands back a cursor, a bookmark you pass to the next call, until it returns 0.

Kinds of value

A pigeonhole can hold a single note, a bundle with pockets, a stack, a guest list or a ranked board.

String
The simplest value: some text, which can also be a number or a chunk of JSON (a common text format for structured data). Up to 512 MB.
Atomic
An atomic step happens completely, with nothing slipping in half way. INCR adds one in a single step, so two apps counting at once never lose a click the way "read, add one, write back" can.
Hash
A small map of fields and values stored under one key, such as a user's name, city and plan. You can read or change one field without touching the others.
For example HSET user:42 name Shree city Delhi
List
An ordered sequence of values. Pushing and popping at the ends is fast, which makes a list a natural queue or a capped log of recent items.
Queue
A line of jobs waiting for a worker, the program that does them. One side pushes jobs in, workers pop them out. LMOVE moves a job to a "processing" list so a worker that crashes doesn't lose it.
Set
An unordered collection of unique values. Adding something already there does nothing, so sets are good for tags, online users and spotting duplicates. You can also ask what two sets share.
Sorted set
A set where every member has a score, kept in score order. It's what leaderboards, priority queues and "everything from the last hour" lookups are built on.
For example ZADD leaderboard 1200 asha

Passing messages around

Two ways to tell other programs something happened: a logbook they read at their own pace, or an announcement over the speaker.

Stream
An append only log: new entries go on the end and old ones stay put. Each entry gets an id made from the time it arrived. Unlike Pub/Sub, entries wait for readers who were away.
Consumer group
A group of workers reading the same stream where each entry goes to exactly one of them. Each worker acknowledges (XACK) what it has finished, and Redis remembers what's still pending so a stuck entry can be handed to someone else.
Pub/Sub
Publish and subscribe. A sender publishes a message to a channel, a named topic, and everyone subscribed right then hears it at once. Anyone not listening misses it, because nothing is stored.

Doing several things safely

What stops two clerks grabbing the same parcel, and how to send a batch of requests in one trip.

Transaction
In Redis, MULTI starts collecting commands and EXEC runs them back to back with nothing in between. Unlike most databases it doesn't undo the others if one fails.
WATCH
Marks keys before a MULTI. If another client changes one of them first, EXEC does nothing and you try again. This is called optimistic locking: assume no clash, check at the end.
Lua script
A small program, in a language called Lua, that runs inside Redis. Nothing else runs until it finishes, so "check the stock, then take some" happens as one step.
Lock
A key that says one worker is busy with something, set only if nobody holds it (NX) and with an expiry so a crashed worker can't hold it forever. Its value is a random owner token, so only the holder can release it.
Rate limit
A cap on how often someone may do something. The simplest one is a counter per client that expires at the end of each time window; once it passes the cap, the app says no.
Pipelining
Sending several commands without waiting for each answer. Every command still runs separately, but they share one round trip over the network, which saves a lot of waiting.

Caching

Keeping copies of slow answers close to hand, and knowing when to throw them away.

Cache
A fast store of copies of data whose real home is somewhere slower, like a main database. Reading the copy saves time, but the copy can go out of date.
Cache-aside
The usual caching pattern: the app looks in Redis, and on a miss reads the database and saves a copy with a TTL. After a write, it deletes the copy so the next read fetches fresh data. That deleting is called invalidation.
Stampede and jitter
When a busy cached key expires, many requests miss at once and all hit the database. A short lock lets one of them rebuild it. Jitter adds a few random seconds to each TTL so keys don't all expire together.

Running it for real

Words you meet once an app depends on Redis every day.

Driver
The library your program uses to talk to Redis. In Node.js the official one is node-redis: you create one client, connect it once and reuse it.
Provider
In NestJS, a framework for building Node.js servers, a provider is something the framework creates once and hands to any part of the app that asks. Here it shares one connected Redis client.
Encoding and listpack
How Redis lays a value out in memory. Small hashes, lists and sets use a compact layout called a listpack. Past a size limit they switch to a roomier one that uses much more memory.
Eviction and maxmemory
maxmemory caps how much memory Redis may use. When it's full, the eviction policy decides which keys to throw out, such as the least recently used. With noeviction, new writes fail instead.
Persistence: RDB and AOF
Because data lives in memory, a restart would wipe it. RDB saves a snapshot every so often; AOF logs every write so you lose at most about a second. Many setups use both.
01

redis-cli and keys

Every value lives under a key. Name keys with a colon separated pattern, give temporary data a TTL, and find keys with SCAN, never KEYS.

SET and GETEXPIRE and TTLSCANKey naming

Redis is single threaded for commands, so one slow command delays every client. KEYS pattern walks the whole keyspace in one go; SCAN does it in small steps with a cursor. Every terminal on this page is real redis-cli output from Redis 7.0.

Terminalredis-cli
127.0.0.1:6379> SET user:42:name Shree
OK
127.0.0.1:6379> GET user:42:name
"Shree"
127.0.0.1:6379> EXISTS user:42:name user:43:name
(integer) 1
127.0.0.1:6379> SET otp:42 739201 EX 300
OK
127.0.0.1:6379> TTL otp:42
(integer) 300
127.0.0.1:6379> PERSIST otp:42
(integer) 1
127.0.0.1:6379> TTL otp:42
(integer) -1
127.0.0.1:6379> TYPE user:42:name
string
127.0.0.1:6379> MSET session:a 1 session:b 2 session:c 3
OK
127.0.0.1:6379> SCAN 0 MATCH "session:*" COUNT 100
1) "0"
2) 1) "session:a"
   2) "session:b"
   3) "session:c"
127.0.0.1:6379> DEL session:a session:b
(integer) 2
127.0.0.1:6379> UNLINK session:c
(integer) 1

Why it matters: a TTL of -1 means the key never expires and -2 means it does not exist. UNLINK frees memory in the background, which matters for big keys.

CommandDoes
SET k v EX 60Set with a TTL in seconds (PX for ms)
SET k v NXOnly if the key does not exist
SET k v XX KEEPTTLOnly if it exists, keeping its TTL
EXPIRE k 60 / TTL kSet and read a TTL
SCAN cursor MATCH p COUNT nIterate keys safely; loop until the cursor is 0
TYPE kstring, hash, list, set, zset, stream
RENAME a bRename atomically
DEL / UNLINKDelete now / delete with background freeing
02

Strings and counters

Strings hold text, JSON or numbers up to 512 MB. INCR family commands make atomic counters with no read, modify, write race.

INCRINCRBYFLOATMGETGETDEL
Terminalredis-cli
127.0.0.1:6379> INCR page:home:views
(integer) 1
127.0.0.1:6379> INCRBY page:home:views 10
(integer) 11
127.0.0.1:6379> INCRBYFLOAT wallet:42 99.50
"99.5"
127.0.0.1:6379> DECR stock:sku-1
(integer) -1
127.0.0.1:6379> SET config:flags '{"beta":true}'
OK
127.0.0.1:6379> MGET page:home:views wallet:42 missing
1) "11"
2) "99.5"
3) (nil)
127.0.0.1:6379> APPEND log:today 'boot;'
(integer) 5
127.0.0.1:6379> APPEND log:today 'ready;'
(integer) 11
127.0.0.1:6379> GET log:today
"boot;ready;"
127.0.0.1:6379> GETDEL log:today
"boot;ready;"
127.0.0.1:6379> STRLEN config:flags
(integer) 13

Why it matters: DECR on a missing key starts from 0 and returns -1. For stock that must never go negative, check and decrement in one Lua script (module 09).

03

Hashes

A hash is a small map of fields under one key: ideal for an object you read and update a field at a time.

HSETHGETALLHINCRBYHMGET

Small hashes are stored as a compact listpack, so a million user hashes use far less memory than a million separate keys. Expiry applies to the whole key in Redis 7.0; per field TTLs (HEXPIRE) arrived in 7.4.

Terminalredis-cli
127.0.0.1:6379> HSET user:42 name Shree city Delhi plan pro
(integer) 3
127.0.0.1:6379> HGET user:42 plan
"pro"
127.0.0.1:6379> HMGET user:42 name city email
1) "Shree"
2) "Delhi"
3) (nil)
127.0.0.1:6379> HINCRBY user:42 logins 1
(integer) 1
127.0.0.1:6379> HSETNX user:42 plan free
(integer) 0
127.0.0.1:6379> HGETALL user:42
1) "name"
2) "Shree"
3) "city"
4) "Delhi"
5) "plan"
6) "pro"
7) "logins"
8) "1"
127.0.0.1:6379> HDEL user:42 city
(integer) 1
127.0.0.1:6379> HLEN user:42
(integer) 3
127.0.0.1:6379> HEXISTS user:42 city
(integer) 0
04

Lists and queues

Lists are ordered and fast at both ends. Push and pop for queues, trim for capped logs, and LMOVE for a queue that survives a worker crash.

LPUSH, RPOPLTRIMBLPOPLMOVE

A worker that pops a job and then crashes loses it. LMOVE queue processing RIGHT LEFT moves the job to a processing list in one step; the worker removes it with LREM when done, and a janitor can requeue anything left behind.

Terminalredis-cli
127.0.0.1:6379> LPUSH jobs email:1 email:2 invoice:3
(integer) 3
127.0.0.1:6379> LLEN jobs
(integer) 3
127.0.0.1:6379> LRANGE jobs 0 -1
1) "invoice:3"
2) "email:2"
3) "email:1"
127.0.0.1:6379> RPOP jobs
"email:1"
127.0.0.1:6379> LMOVE jobs jobs:processing RIGHT LEFT
"email:2"
127.0.0.1:6379> LREM jobs:processing 1 email:2
(integer) 1
127.0.0.1:6379> LPUSH recent:42 p1 p2 p3 p4 p5 p6
(integer) 6
127.0.0.1:6379> LTRIM recent:42 0 4
OK
127.0.0.1:6379> LRANGE recent:42 0 -1
1) "p6"
2) "p5"
3) "p4"
4) "p3"
5) "p2"
127.0.0.1:6379> BLPOP empty:queue 1
(nil)

Why it matters: BLPOP key timeout waits for an item instead of polling. It returned (nil) here after one second because the queue was empty.

05

Sets

Unordered collections of unique strings with fast membership checks and set algebra: tags, followers, online users, deduplication.

SADDSISMEMBERSINTERSRANDMEMBER
Terminalredis-cli
127.0.0.1:6379> SADD tags:post:1 redis cache nosql
(integer) 3
127.0.0.1:6379> SADD tags:post:2 redis queue
(integer) 2
127.0.0.1:6379> SADD tags:post:1 redis
(integer) 0
127.0.0.1:6379> SISMEMBER tags:post:1 cache
(integer) 1
127.0.0.1:6379> SMISMEMBER tags:post:1 cache queue
1) (integer) 1
2) (integer) 0
127.0.0.1:6379> SINTER tags:post:1 tags:post:2
1) "redis"
127.0.0.1:6379> SUNION tags:post:1 tags:post:2
1) "nosql"
2) "redis"
3) "queue"
4) "cache"
127.0.0.1:6379> SDIFF tags:post:1 tags:post:2
1) "nosql"
2) "cache"
127.0.0.1:6379> SCARD tags:post:1
(integer) 3

Why it matters: SADD returns how many members were new, so adding redis again returned 0. That makes a set a cheap deduplication filter for event ids.

06

Sorted sets

Unique members ordered by a score. The structure behind leaderboards, priority queues, time indexes and sliding window rate limits.

ZADDZINCRBYZRANGE ... REVBYSCORE
Terminalredis-cli
127.0.0.1:6379> ZADD leaderboard 1200 asha 950 ben 1430 chen 1100 dev
(integer) 4
127.0.0.1:6379> ZINCRBY leaderboard 300 ben
"1250"
127.0.0.1:6379> ZRANGE leaderboard 0 2 REV WITHSCORES
1) "chen"
2) "1430"
3) "ben"
4) "1250"
5) "asha"
6) "1200"
127.0.0.1:6379> ZREVRANK leaderboard ben
(integer) 1
127.0.0.1:6379> ZSCORE leaderboard dev
"1100"
127.0.0.1:6379> ZRANGE leaderboard 1000 1300 BYSCORE WITHSCORES
1) "dev"
2) "1100"
3) "asha"
4) "1200"
5) "ben"
6) "1250"
127.0.0.1:6379> ZCOUNT leaderboard "(1200" +inf
(integer) 2
# a time index: score = unix time
127.0.0.1:6379> ZADD events 1790000000 e1 1790000300 e2 1790000900 e3
(integer) 3
127.0.0.1:6379> ZREMRANGEBYSCORE events -inf 1790000300
(integer) 2
127.0.0.1:6379> ZRANGE events 0 -1
1) "e3"

Why it matters: "(1200" means strictly greater than 1200. Scores are 64 bit floats, so timestamps in seconds or milliseconds make a natural time index.

07

Streams

An append only log with ids, consumer groups and acknowledgements. Use it when Pub/Sub's fire and forget is not enough.

XADDConsumer groupsXACKXPENDING

Each entry gets an id made of a millisecond timestamp and a sequence number. A consumer group hands every entry to exactly one consumer, remembers what each one has not acknowledged, and lets another consumer claim stuck entries with XAUTOCLAIM. MAXLEN ~ 1000 caps the stream cheaply.

Terminalredis-cli
127.0.0.1:6379> XADD orders * id 101 total 1999
"1790849298304-0"
127.0.0.1:6379> XADD orders * id 102 total 499
"1790849298308-0"
127.0.0.1:6379> XLEN orders
(integer) 2
127.0.0.1:6379> XGROUP CREATE orders billing 0
OK
127.0.0.1:6379> XREADGROUP GROUP billing worker-1 COUNT 1 STREAMS orders >
1) 1) "orders"
   2) 1) 1) "1790849298304-0"
         2) 1) "id"
            2) "101"
            3) "total"
            4) "1999"
127.0.0.1:6379> XACK orders billing 1790849298304-0
(integer) 1
127.0.0.1:6379> XREADGROUP GROUP billing worker-2 COUNT 5 STREAMS orders >
1) 1) "orders"
   2) 1) 1) "1790849298308-0"
         2) 1) "id"
            2) "102"
            3) "total"
            4) "499"
127.0.0.1:6379> XPENDING orders billing
1) (integer) 1
2) "1790849298308-0"
3) "1790849298308-0"
4) 1) 1) "worker-2"
      2) "1"
127.0.0.1:6379> XADD audit MAXLEN ~ 1000 * action login
"1790849298350-0"

Why it matters: > means entries never delivered to this group. The second worker got only order 102, and XPENDING still shows it as unacknowledged until worker-2 sends XACK.

08

Pub/Sub

Publish a message to a channel and every connected subscriber gets it right away. Nobody listening means nobody gets it.

SUBSCRIBEPUBLISHPSUBSCRIBEAt most once

Pub/Sub keeps nothing: a subscriber that is down or reconnecting misses messages. That suits cache invalidation, live notifications and socket fan out across servers. For work that must not be lost, use a stream. A subscribed connection can only run subscribe commands, so clients use a separate connection for it.

Terminalredis-cli, publisher
127.0.0.1:6379> PUBLISH orders:created '{"id":101}'
(integer) 1
127.0.0.1:6379> PUBLISH orders:created '{"id":102}'
(integer) 1
127.0.0.1:6379> PUBSUB NUMSUB orders:created
1) "orders:created"
2) (integer) 1

Why it matters: PUBLISH returns the number of clients that received the message: here, one.

Terminalredis-cli, subscriber
127.0.0.1:6379> SUBSCRIBE orders:created
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "orders:created"
3) (integer) 1
1) "message"
2) "orders:created"
3) "{\"id\":101}"
1) "message"
2) "orders:created"
3) "{\"id\":102}"
09

Transactions and Lua

MULTI queues commands and runs them back to back. WATCH aborts if a key changed. A Lua script runs check and update as one atomic step.

MULTI / EXECWATCHEVALAtomic check and set

MULTI does not roll back: if one queued command fails, the others still run. Its job is isolation, nothing runs in between. When you need to read a value and decide, either WATCH the key and retry on failure, or move the logic into a Lua script, which no other command can interrupt.

Terminalredis-cli
127.0.0.1:6379> SET stock:sku-9 3
OK
127.0.0.1:6379> EVAL "local s = tonumber(redis.call('GET', KEYS[1]) or '0') if s >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) end return -1" 1 stock:sku-9 2
(integer) 1
127.0.0.1:6379> EVAL "local s = tonumber(redis.call('GET', KEYS[1]) or '0') if s >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) end return -1" 1 stock:sku-9 2
(integer) -1
127.0.0.1:6379> GET stock:sku-9
"1"

Why it matters: the script decremented only while enough stock was left; the second call returned -1 instead of going negative. In production, load it once with SCRIPT LOAD or FUNCTION LOAD and call it by hash or name.

ToolGives youUse when
MULTI/EXECCommands run together, in orderSeveral writes that belong together
WATCH + MULTIOptimistic locking; EXEC returns nil on conflictRead then write, low contention
EVAL / FUNCTIONServer side logic, fully atomicCheck and set, rate limits, stock
PipeliningMany commands, one round tripThroughput; no atomicity
10

Caching patterns

Cache-aside with a TTL, jitter so keys do not all expire at once, a lock against stampedes, and deleting on write.

Cache-asideTTL jitterStampede lockInvalidation

The hard part of caching is not storing values but deciding when they are wrong. Delete the cached copy after a successful write rather than updating it, so a failed write never leaves the cache ahead of the database. When a hot key expires, many requests miss together; a short lock lets one rebuild it.

cache.tsTS
import { createClient } from 'redis';

const redis = await createClient().connect();

let dbCalls = 0;
async function loadProductFromDb(id: number) {
  dbCalls++;
  await new Promise((r) => setTimeout(r, 50));      // pretend this is a slow query
  return { id, name: `Product ${id}`, price: 1999 };
}

// Cache-aside: read the cache, fall back to the source, then fill the cache
async function getProduct(id: number) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Awaited<ReturnType<typeof loadProductFromDb>>;

  // Stampede guard: only one caller rebuilds, the rest wait briefly and retry
  const lock = await redis.set(`${key}:lock`, '1', { condition: 'NX', expiration: { type: 'PX', value: 2000 } });
  if (!lock) {
    await new Promise((r) => setTimeout(r, 80));
    return getProduct(id);
  }
  try {
    const product = await loadProductFromDb(id);
    const ttl = 300 + Math.floor(Math.random() * 60);   // jitter so keys do not all expire together
    await redis.set(key, JSON.stringify(product), { expiration: { type: 'EX', value: ttl } });
    return product;
  } finally {
    await redis.del(`${key}:lock`);
  }
}

// Invalidate on write
async function updatePrice(id: number, price: number) {
  // ...write to the database first, then drop the cached copy
  await redis.del(`product:${id}`);
}

await redis.del(['product:7', 'product:7:lock']);
const results = await Promise.all(Array.from({ length: 20 }, () => getProduct(7)));
console.log('20 concurrent reads, db calls:', dbCalls, results[0]);
console.log('ttl seconds:', await redis.ttl('product:7'));

await updatePrice(7, 1499);
await getProduct(7);
console.log('after invalidation, db calls:', dbCalls);
await redis.quit();
Terminaltsx
~/db $ npx tsx cache.ts
20 concurrent reads, db calls: 1 { id: 7, name: 'Product 7', price: 1999 }
ttl seconds: 350
after invalidation, db calls: 2

Why it matters: twenty concurrent reads caused a single database call. The rest found the lock, waited 80 ms and then read the cache.

PatternHowTrade off
Cache-asideApp reads cache, falls back to DB, fills cacheSimple; first read is slow
Write-throughApp writes cache and DB togetherFresher reads; slower writes
Write-behindWrite cache, flush to DB laterFast writes; can lose data
TTL onlyLet entries expireStale for up to one TTL
Delete on writeDEL after the DB commitNext read pays the rebuild
11

Locks and rate limiting

A lock is a key set with NX and an expiry, released only by its owner. A rate limit is a counter that expires with its window.

SET NX PXOwner tokenFixed windowSliding window

Always give a lock an expiry, so a crashed holder cannot block everyone forever, and a random owner value, so a slow worker whose lock already expired cannot delete someone else's. Release with a script that checks the owner first. A single Redis lock is fine for efficiency; for correctness across failures, add fencing tokens in the database.

Terminalredis-cli
127.0.0.1:6379> SET lock:invoice:77 worker-a-7f3 NX PX 30000
OK
127.0.0.1:6379> SET lock:invoice:77 worker-b-91c NX PX 30000
(nil)
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0" 1 lock:invoice:77 worker-b-91c
(integer) 0
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0" 1 lock:invoice:77 worker-a-7f3
(integer) 1
# fixed window: 3 requests per minute for one client
127.0.0.1:6379> INCR rl:ip:10.0.0.7:29848333
(integer) 1
127.0.0.1:6379> EXPIRE rl:ip:10.0.0.7:29848333 60 NX
(integer) 1
127.0.0.1:6379> INCR rl:ip:10.0.0.7:29848333
(integer) 2
127.0.0.1:6379> INCR rl:ip:10.0.0.7:29848333
(integer) 3
127.0.0.1:6379> INCR rl:ip:10.0.0.7:29848333
(integer) 4

Why it matters: worker-b could not take the lock and could not release it either; only the owner's token deleted it. The fourth request returned 4, which is over the limit of 3, so the app rejects it.

LimiterDataBehaviour
Fixed windowINCR plus EXPIRECheap; allows a burst at window edges
Sliding logSorted set of timestampsExact; one member per request
Sliding counterTwo fixed windows, weightedClose to exact, cheap
Token bucketHash with tokens and last refill, in LuaSmooth, allows controlled bursts
12

From Node.js with node-redis

The official client: commands as camelCase methods, MULTI and WATCH, scan iterators and automatic pipelining.

createClientmulti()WatchErrorscanIterator

node-redis pipelines commands sent in the same tick automatically, so Promise.all over several commands costs one round trip. Replies keep Redis types: hash fields and GET results are strings, while INCR returns a number. Use duplicate() for a connection that needs its own state, such as WATCH or SUBSCRIBE.

redis-demo.tsTS
import { createClient } from 'redis';

// One client per process; it reconnects on its own if the connection drops
const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' });
redis.on('error', (err) => console.error('redis error', err));
await redis.connect();

// Plain commands map to camelCase methods
await redis.set('greeting', 'namaste', { expiration: { type: 'EX', value: 60 } });
console.log(await redis.get('greeting'), await redis.ttl('greeting'));

// Hashes come back as plain objects
await redis.hSet('user:42', { name: 'Shree', plan: 'pro', logins: 0 });
await redis.hIncrBy('user:42', 'logins', 1);
console.log(await redis.hGetAll('user:42'));

// MULTI: queue commands, run them together, get every reply at once
const [count, , members] = await redis.multi()
  .incr('page:views')
  .sAdd('online', ['u1', 'u2'])
  .sMembers('online')
  .exec();
console.log('multi', count, members);

// Optimistic locking: WATCH a key, and EXEC fails if someone changed it
await redis.set('stock:sku-1', '5');
const tx = await redis.duplicate().connect();   // an isolated connection for the transaction
await tx.watch('stock:sku-1');
const stock = Number(await tx.get('stock:sku-1'));
await redis.set('stock:sku-1', '4');            // another client changes it meanwhile
try {
  await tx.multi().set('stock:sku-1', String(stock - 1)).exec();
} catch (err) {
  console.log('watch failed:', (err as Error).constructor.name);
}
await tx.quit();

// SCAN without blocking the server: an async iterator over matching keys
await redis.mSet({ 'session:a': '1', 'session:b': '2', 'session:c': '3' });
const sessions: string[] = [];
for await (const keys of redis.scanIterator({ MATCH: 'session:*', COUNT: 100 })) sessions.push(...keys);
console.log('scan', sessions.sort());

// Pipelining: commands sent without waiting, replies in order
const replies = await Promise.all([redis.incr('a'), redis.incr('a'), redis.get('a')]);
console.log('pipelined', replies);

await redis.quit();
Terminaltsx
~/db $ npx tsx redis-demo.ts
namaste 60
{ name: 'Shree', plan: 'pro', logins: '1' }
multi 1 [ 'u2', 'u1' ]
watch failed: WatchError
scan [ 'session:a', 'session:b', 'session:c' ]
pipelined [ 1, 2, '2' ]
13

Redis in NestJS

Expose one connected client as a global provider, close it on shutdown, and use it from guards and services.

useFactory@Global moduleonApplicationShutdownRate limit guard

An async useFactory makes Nest wait for the connection before the app accepts requests. Both files compile in the NestJS cheatsheet's project against NestJS 12 and node-redis 6. For caching handlers, @nestjs/cache-manager with a Redis store is the higher level option.

src/redis/redis.module.tsTS
import { Global, Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient } from 'redis';

export const REDIS = Symbol('REDIS');
const makeClient = (url: string) => createClient({ url });
export type Redis = ReturnType<typeof makeClient>;

@Global()
@Module({
  providers: [
    {
      provide: REDIS,
      inject: [ConfigService],
      // An async factory: Nest waits for the connection before the app starts
      useFactory: async (config: ConfigService): Promise<Redis> => {
        const client = makeClient(config.get('REDIS_URL', 'redis://localhost:6379'));
        client.on('error', (err) => console.error('redis error', err));
        await client.connect();
        return client;
      },
    },
  ],
  exports: [REDIS],
})
export class RedisModule implements OnApplicationShutdown {
  constructor(@Inject(REDIS) private readonly redis: Redis) {}

  async onApplicationShutdown() {
    await this.redis.quit();          // close cleanly on SIGTERM
  }
}
src/redis/rate-limit.guard.tsTS
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { REDIS, type Redis } from './redis.module.js';

// Fixed window: at most 100 requests per client per minute
@Injectable()
export class RateLimitGuard implements CanActivate {
  constructor(@Inject(REDIS) private readonly redis: Redis) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const window = Math.floor(Date.now() / 60_000);
    const key = `rl:${req.ip}:${window}`;

    const [count] = await this.redis.multi().incr(key).expire(key, 60).exec();
    if (Number(count) > 100) {
      throw new HttpException('Too many requests', HttpStatus.TOO_MANY_REQUESTS);
    }
    return true;
  }
}

Why it matters: multi() sends INCR and EXPIRE together, so a counter can never be left without an expiry if the process dies between the two.

14

Memory and eviction

Everything lives in RAM, so know what a key costs, cap the total, and choose what Redis drops when it is full.

MEMORY USAGEEncodingsmaxmemoryEviction policies

Small hashes, lists, sets and sorted sets use a compact encoding until they pass a size threshold, then switch to a real hash table. Set maxmemory on every cache. Without a limit Redis grows until the operating system kills it; with noeviction writes start failing when it is full.

Terminalredis-cli
127.0.0.1:6379> OBJECT ENCODING small:hash
"listpack"
127.0.0.1:6379> OBJECT ENCODING big:hash
"hashtable"
127.0.0.1:6379> MEMORY USAGE small:hash
(integer) 88
127.0.0.1:6379> MEMORY USAGE big:hash
(integer) 3056
127.0.0.1:6379> CONFIG GET "hash-max-listpack-*"
1) "hash-max-listpack-value"
2) "64"
3) "hash-max-listpack-entries"
4) "512"
127.0.0.1:6379> CONFIG SET maxmemory 256mb
OK
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lru
OK
127.0.0.1:6379> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"
~/db $ redis-cli INFO memory | grep -E 'used_memory_human|maxmemory|fragmentation'
used_memory_human:2.79M
used_memory_peak_human:2.83M
maxmemory_human:256.00M
maxmemory_policy:allkeys-lru
mem_fragmentation_ratio:2.86

Why it matters: the second hash holds 80 byte values, past the 64 byte listpack limit, so Redis switched it to a full hash table. It also stops using a listpack after 512 fields. Keep values short and hashes small when memory matters.

PolicyEvictsFits
noevictionNothing; writes fail when fullQueues and data you cannot lose
allkeys-lruLeast recently used, any keyA pure cache
allkeys-lfuLeast frequently used, any keyCaches with a stable hot set
volatile-lruLRU among keys with a TTLCache and permanent data in one instance
volatile-ttlKeys closest to expiringTTLs that reflect value
15

Persistence and operations

Choose how much data you can lose, find slow commands and big keys, and watch the numbers that warn of trouble.

RDBAOFSLOWLOG--bigkeys

RDB writes point in time snapshots: compact and fast to restart from, but you lose writes since the last one. AOF logs every write; with appendfsync everysec you lose at most about a second. Many setups use both. This demo server runs with persistence off, which is fine for a pure cache and wrong for a queue.

Terminalredis-cli
127.0.0.1:6379> CONFIG GET appendonly
1) "appendonly"
2) "no"
127.0.0.1:6379> CONFIG GET save
1) "save"
2) ""
127.0.0.1:6379> BGSAVE
Background saving started
127.0.0.1:6379> SLOWLOG GET 2
1) 1) (integer) 5
   2) (integer) 1790849303
   3) (integer) 42
   4) 1) "keys"
      2) "*"
   5) "127.0.0.1:48846"
   6) ""
2) 1) (integer) 4
   2) (integer) 1790849303
   3) (integer) 6
   4) 1) "config"
      2) "set"
      3) "slowlog-log-slower-than"
      4) "0"
   5) "127.0.0.1:48830"
   6) ""
127.0.0.1:6379> INFO keyspace
# Keyspace
db0:keys=27,expires=3,avg_ttl=51305
127.0.0.1:6379> DBSIZE
(integer) 27
~/db $ redis-cli --bigkeys
[00.00%] Biggest set    found so far '"tags:post:1"' with 3 members
[00.00%] Biggest stream found so far '"orders"' with 2 entries
[00.00%] Biggest string found so far '"a"' with 1 bytes
[00.00%] Biggest zset   found so far '"leaderboard"' with 4 members
[00.00%] Biggest string found so far '"page:home:views"' with 2 bytes
[00.00%] Biggest hash   found so far '"small:hash"' with 2 fields
redis.confCONF
# Snapshot if at least 1 change in 3600 s, 100 in 300 s, or 10000 in 60 s
save 3600 1 300 100 60 10000

# Append only file, fsync once a second
appendonly yes
appendfsync everysec

# Cap memory and pick an eviction policy
maxmemory 2gb
maxmemory-policy allkeys-lru

# Log commands slower than 10 ms, keep the last 128
slowlog-log-slower-than 10000
slowlog-max-len 128

# Require a password, and rename or disable dangerous commands with ACLs
requirepass change-me
WatchWhereWorry when
MemoryINFO memoryClose to maxmemory, or fragmentation well above 1.5
EvictionsINFO stats evicted_keysRising on a store that should not evict
Hit ratiokeyspace_hits and keyspace_missesFalling after a deploy
Slow commandsSLOWLOG GETKEYS, big HGETALL, SMEMBERS on large sets
ClientsINFO clientsconnected_clients climbing: a connection leak
ReplicationINFO replicationReplica lag growing
16

Which one do I need?

Start from what you are trying to do, then reach for the tool in the middle column. The last column takes you to the module that explains it.

I want toReach forExampleModule
Store a value that expiresSET with EXSET otp:42 739201 EX 30001
List keys safelySCANSCAN 0 MATCH user:* COUNT 10001
Count something atomicallyINCRINCR page:home:views02
Store an object by fieldHashHSET user:42 name Shree03
A simple job queueListLPUSH / BLPOP04
A queue that survives crashesLMOVELMOVE jobs processing RIGHT LEFT04
Unique tags or dedupe idsSetSADD seen:events e105
A leaderboardSorted setZINCRBY board 10 user06
Durable events with workersStream and consumer groupXREADGROUP ... STREAMS s >07
Broadcast to live listenersPub/SubPUBLISH channel msg08
Check then update atomicallyLua scriptEVAL "..." 1 key09
Cache a slow queryCache-aside with TTLSET k v EX 30010
Stop a cache stampedeLock keySET k:lock 1 NX PX 200010
Run a job on one worker onlyLock with owner tokenSET lock v NX PX 3000011
Limit requests per minuteINCR and EXPIREINCR rl:ip:window11
Several commands, one round tripPipeliningPromise.all([...])12
Share one client in NestJSAsync factory provideruseFactory: async () => client13
Use Redis as a pure cacheEviction policymaxmemory-policy allkeys-lru14
Lose at most a second of dataAOFappendfsync everysec15