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.
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
- Think of it as a post room where everything sits within arm's reach
- 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
- Think of it as the label on a pigeonhole
- 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:nameholds the name of user 42. - redis-cli and commands
- Think of it as the hatch where you call out requests to the post room clerk
- redis-cli is the program you open in a terminal to talk to Redis. Everything you ask is a command: a word like
SETorGETfollowed by a key and maybe a value. - For example
SET user:42:name ShreethenGET user:42:name - TTL
- Think of it as a "clear out on Friday" sticker on a pigeonhole
- 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.
-1means no expiry;-2means the key isn't there. - For example
SET otp:42 739201 EX 300keeps the code for five minutes. - SCAN and cursor
- Think of it as checking the pigeonholes one wall at a time, with a note of where you got to
KEYSlists every key in one go and blocks Redis while it does.SCANwalks 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
- Think of it as a single slip of paper in the pigeonhole
- 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
- Think of it as a tally clicker only one person can press at a time
- An atomic step happens completely, with nothing slipping in half way.
INCRadds one in a single step, so two apps counting at once never lose a click the way "read, add one, write back" can. - Hash
- Think of it as an envelope with labelled pockets inside one pigeonhole
- 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
- Think of it as a stack of letters you can add to or take from at either end
- 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
- Think of it as an in-tray: first letter in, first letter dealt with
- A line of jobs waiting for a worker, the program that does them. One side pushes jobs in, workers pop them out.
LMOVEmoves a job to a "processing" list so a worker that crashes doesn't lose it. - Set
- Think of it as a guest list where each name can appear only once
- 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
- Think of it as a scoreboard pinned on the post room wall
- 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
- Think of it as a logbook where each delivery is written on a new numbered line
- 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
- Think of it as a team of sorters sharing one logbook, each initialling the lines they finish
- 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
- Think of it as an announcement over the post room speaker
- 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
- Think of it as handing the clerk a bundle of request slips to deal with in one go
- In Redis,
MULTIstarts collecting commands andEXECruns them back to back with nothing in between. Unlike most databases it doesn't undo the others if one fails. - WATCH
- Think of it as keeping an eye on a pigeonhole and calling off your bundle if anyone touches it
- Marks keys before a
MULTI. If another client changes one of them first,EXECdoes nothing and you try again. This is called optimistic locking: assume no clash, check at the end. - Lua script
- Think of it as a written procedure the clerk follows start to finish without stopping
- 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
- Think of it as a "being sorted, do not touch" card with your name on it
- 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
- Think of it as "three parcels per customer per minute"
- 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
- Think of it as handing over ten slips at once instead of walking back after each one
- 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
- Think of it as a tray of photocopies of the letters people ask for most
- 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
- Think of it as checking the tray first, and only walking to the archive when it's empty
- 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
- Think of it as a crowd rushing the archive the moment a popular copy is thrown out
- 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
- Think of it as a messenger who carries your app's requests to the post room
- 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
- Think of it as the front desk that hands every department the same messenger
- 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
- Think of it as packing small parcels tightly in one box until they outgrow it
- 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
- Think of it as clearing out old pigeonholes when the room is full
maxmemorycaps 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. Withnoeviction, new writes fail instead.- Persistence: RDB and AOF
- Think of it as photographing the room now and then, or writing every change in a ledger
- 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.
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.
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.
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.
| Command | Does |
|---|---|
SET k v EX 60 | Set with a TTL in seconds (PX for ms) |
SET k v NX | Only if the key does not exist |
SET k v XX KEEPTTL | Only if it exists, keeping its TTL |
EXPIRE k 60 / TTL k | Set and read a TTL |
SCAN cursor MATCH p COUNT n | Iterate keys safely; loop until the cursor is 0 |
TYPE k | string, hash, list, set, zset, stream |
RENAME a b | Rename atomically |
DEL / UNLINK | Delete now / delete with background freeing |
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.
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).
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.
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.
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
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.
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.
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.
Sets
Unordered collections of unique strings with fast membership checks and set algebra: tags, followers, online users, deduplication.
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.
Sorted sets
Unique members ordered by a score. The structure behind leaderboards, priority queues, time indexes and sliding window rate limits.
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.
Streams
An append only log with ids, consumer groups and acknowledgements. Use it when Pub/Sub's fire and forget is not enough.
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.
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.
Pub/Sub
Publish a message to a channel and every connected subscriber gets it right away. Nobody listening means nobody gets it.
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.
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.
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}"
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 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.
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.
| Tool | Gives you | Use when |
|---|---|---|
MULTI/EXEC | Commands run together, in order | Several writes that belong together |
WATCH + MULTI | Optimistic locking; EXEC returns nil on conflict | Read then write, low contention |
EVAL / FUNCTION | Server side logic, fully atomic | Check and set, rate limits, stock |
| Pipelining | Many commands, one round trip | Throughput; no atomicity |
Caching patterns
Cache-aside with a TTL, jitter so keys do not all expire at once, a lock against stampedes, and deleting on write.
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.
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();~/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.
| Pattern | How | Trade off |
|---|---|---|
| Cache-aside | App reads cache, falls back to DB, fills cache | Simple; first read is slow |
| Write-through | App writes cache and DB together | Fresher reads; slower writes |
| Write-behind | Write cache, flush to DB later | Fast writes; can lose data |
| TTL only | Let entries expire | Stale for up to one TTL |
| Delete on write | DEL after the DB commit | Next read pays the rebuild |
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.
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.
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.
| Limiter | Data | Behaviour |
|---|---|---|
| Fixed window | INCR plus EXPIRE | Cheap; allows a burst at window edges |
| Sliding log | Sorted set of timestamps | Exact; one member per request |
| Sliding counter | Two fixed windows, weighted | Close to exact, cheap |
| Token bucket | Hash with tokens and last refill, in Lua | Smooth, allows controlled bursts |
From Node.js with node-redis
The official client: commands as camelCase methods, MULTI and WATCH, scan iterators and automatic pipelining.
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.
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();~/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' ]
Redis in NestJS
Expose one connected client as a global provider, close it on shutdown, and use it from guards and services.
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.
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
}
}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.
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.
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.
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.
| Policy | Evicts | Fits |
|---|---|---|
noeviction | Nothing; writes fail when full | Queues and data you cannot lose |
allkeys-lru | Least recently used, any key | A pure cache |
allkeys-lfu | Least frequently used, any key | Caches with a stable hot set |
volatile-lru | LRU among keys with a TTL | Cache and permanent data in one instance |
volatile-ttl | Keys closest to expiring | TTLs that reflect value |
Persistence and operations
Choose how much data you can lose, find slow commands and big keys, and watch the numbers that warn of trouble.
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.
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
# 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| Watch | Where | Worry when |
|---|---|---|
| Memory | INFO memory | Close to maxmemory, or fragmentation well above 1.5 |
| Evictions | INFO stats evicted_keys | Rising on a store that should not evict |
| Hit ratio | keyspace_hits and keyspace_misses | Falling after a deploy |
| Slow commands | SLOWLOG GET | KEYS, big HGETALL, SMEMBERS on large sets |
| Clients | INFO clients | connected_clients climbing: a connection leak |
| Replication | INFO replication | Replica lag growing |
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 to | Reach for | Example | Module |
|---|---|---|---|
| Store a value that expires | SET with EX | SET otp:42 739201 EX 300 | 01 |
| List keys safely | SCAN | SCAN 0 MATCH user:* COUNT 100 | 01 |
| Count something atomically | INCR | INCR page:home:views | 02 |
| Store an object by field | Hash | HSET user:42 name Shree | 03 |
| A simple job queue | List | LPUSH / BLPOP | 04 |
| A queue that survives crashes | LMOVE | LMOVE jobs processing RIGHT LEFT | 04 |
| Unique tags or dedupe ids | Set | SADD seen:events e1 | 05 |
| A leaderboard | Sorted set | ZINCRBY board 10 user | 06 |
| Durable events with workers | Stream and consumer group | XREADGROUP ... STREAMS s > | 07 |
| Broadcast to live listeners | Pub/Sub | PUBLISH channel msg | 08 |
| Check then update atomically | Lua script | EVAL "..." 1 key | 09 |
| Cache a slow query | Cache-aside with TTL | SET k v EX 300 | 10 |
| Stop a cache stampede | Lock key | SET k:lock 1 NX PX 2000 | 10 |
| Run a job on one worker only | Lock with owner token | SET lock v NX PX 30000 | 11 |
| Limit requests per minute | INCR and EXPIRE | INCR rl:ip:window | 11 |
| Several commands, one round trip | Pipelining | Promise.all([...]) | 12 |
| Share one client in NestJS | Async factory provider | useFactory: async () => client | 13 |
| Use Redis as a pure cache | Eviction policy | maxmemory-policy allkeys-lru | 14 |
| Lose at most a second of data | AOF | appendfsync everysec | 15 |