Redis is an in-memory key-value store: commands take fractions of a millisecond, and every key can have a time to live (TTL). That's why it's used as a cache in front of a slow database or API, and for counters, rate limits, short-lived codes and locks. The main caching pattern is cache-aside: the application looks in Redis first, on a miss loads the data from the source and stores it with a TTL, and when the data changes it deletes the stale key.
After this you will be able to
- run Redis 8.10 in Docker and work with strings, hashes, sets and lists in
redis-cli - explain how
TTLworks, which commands reset it and what happens when memory runs out - build a cache-aside cache with node-redis, including invalidation and a fallback when Redis is down
- build a counter with
INCRand a form rate limit withMULTI,INCRandEXPIRE NX
Know beforehand
Verified with
Redis 8.10.2 (redis:8.10-alpine)node-redis 6.3.0Node.js 24 LTS (24.18)Express 5.3.0
Checked against docs: October 11, 2026
Contents
What Redis is and when you need it
Redis is an in-memory data structure server. Every value lives under a string key, and the value can be a string, a hash, a list, a set and other types. The data is in memory, so reads and writes are very fast; Redis can save snapshots (RDB) or a command log (AOF) to disk, but that's configuration, not a default guarantee.
A slow database query or external API call
- How Redis solves it
- a cache with a TTL: repeated reads come from memory
Counters for views, likes, login attempts
- How Redis solves it
- atomic
INCRwith no races between servers
Rate limiting
- How Redis solves it
- a counter per time window that disappears on its own via TTL
Short-lived data: verification codes, sessions
- How Redis solves it
SETwithEX— the key deletes itself
Background job queues
- How Redis solves it
- lists and streams; libraries such as BullMQ build on them
Redis doesn't replace your main database: it's a fast layer next to it. The source of truth is PostgreSQL or another DBMS, while Redis holds copies and temporary data whose loss doesn't break the business. Running PostgreSQL is covered in its own lesson in this track; here a simulated slow database plays its part, so the lesson doesn't depend on a schema.
Running it in Docker and the first commands
The container publishes its port on 127.0.0.1 only: the Redis security docs state that Redis is designed for trusted clients in trusted environments and its port shouldn't be reachable from the internet. Port 56391 avoids a clash with another Redis on the standard 6379.
docker run -d --rm --name redis-lab -p 127.0.0.1:56391:6379 redis:8.10-alpine
docker exec redis-lab redis-cli INFO server | grep redis_version
docker exec -it redis-lab redis-cli$ docker exec redis-lab redis-cli INFO server | grep redis_version
redis_version:8.10.2
$ docker exec redis-lab redis-cli PING
PONGA string is the basic Redis type: a sequence of bytes that can hold text, JSON or a number. A number in a string can be incremented atomically: INCR adds one, INCRBY adds a given amount. Atomic means two application servers incrementing the counter at the same moment won't lose a single increment.
> SET site:title "Demo Booking"
OK
> GET site:title
"Demo Booking"
> SET page:views 10
OK
> INCR page:views
(integer) 11
> INCRBY page:views 5
(integer) 16
> INCR site:title
(error) ERR value is not an integer or out of range
> GET missing:key
(nil)Hashes, sets and lists in brief
Besides strings, everyday work needs three more types. A hash is a set of field-value pairs under one key, much like an object or dictionary. A set is an unordered collection of unique strings, and a membership check is O(1) — it doesn't depend on the set's size. A list holds strings in insertion order.
> HSET service:1 title "Haircut" duration_min 60 price_kzt 8000
(integer) 3
> HGET service:1 price_kzt
"8000"
> HGETALL service:1
1) "title"
2) "Haircut"
3) "duration_min"
4) "60"
5) "price_kzt"
6) "8000"
> HINCRBY service:1 price_kzt 500
(integer) 8500
> SADD service:1:masters "aigerim" "dana" "aigerim"
(integer) 2
> SCARD service:1:masters
(integer) 2
> SISMEMBER service:1:masters "dana"
(integer) 1
> SMEMBERS service:1:masters
1) "aigerim"
2) "dana"
> RPUSH leads:recent "lead-101" "lead-102" "lead-103"
(integer) 3
> LRANGE leads:recent 0 -1
1) "lead-101"
2) "lead-102"
3) "lead-103"
> TYPE service:1
hash
> GET service:1
(error) WRONGTYPE Operation against a key holding the wrong kind of valueString
- Commands
SET,GET,INCR,SET … EX- When to use it
- a cached JSON response, counters, codes, flags
Hash
- Commands
HSET,HGET,HGETALL,HINCRBY- When to use it
- an object whose fields are read and changed separately; per-category counters
Set
- Commands
SADD,SISMEMBER,SCARD,SMEMBERS- When to use it
- unique values: tags, participants, "already processed"
List
- Commands
RPUSH,LPUSH,LRANGE,LTRIM- When to use it
- a feed of recent events, simple queues
A key's type is fixed when it's created: Redis rejects a string GET on a hash with a WRONGTYPE error. The order of SMEMBERS isn't guaranteed — a set is unordered. Agree on key names with colon-separated prefixes: service:1, cache:services:v1, rl:lead-form:<ip> — that makes keys easy to find and delete in groups.
TTL and eviction
TTL (time to live) is a key's lifetime in seconds. You can set it when writing (SET key value EX 60) or later with EXPIRE. TTL returns the remaining seconds, -1 if there's no expiry and -2 if the key doesn't exist. The session below shows the behavior that surprises people most:
> SET otp:demo-user "4821" EX 60
OK
> TTL otp:demo-user
(integer) 60
> TTL site:title
(integer) -1
> TTL no:such:key
(integer) -2
> EXPIRE site:title 2
(integer) 1
# ...3 seconds later
> GET site:title
(nil)
> TTL site:title
(integer) -2
> SET visits:today 1 EX 100
OK
> INCR visits:today
(integer) 2
> TTL visits:today
(integer) 99
> SET visits:today 5
OK
> TTL visits:today
(integer) -1
> SET lock:report 1 NX EX 30
OK
> SET lock:report 1 NX EX 30
(nil)
> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"INCR,HSETandLPUSHchange the value but leave the TTL alone;SET,GETSETandDELoverwrite the key and clear the expiry — as theEXPIREdocs describe.EXPIREwithNXsets an expiry only if there isn't one;XXonly if there is;GTandLTonly if the new expiry is greater or less than the current one.SET … NX EX 30writes the key only if it doesn't exist: the second call returned(nil). That's the basis of a simple "report is already being built" lock.- Redis removes expired keys in two ways: when a key is accessed, and by a background check of a random sample of keys with an expiry.
A separate question is what happens when memory runs out. By default maxmemory is 0 (no limit on 64-bit systems) and maxmemory-policy is noeviction: when the limit is hit, Redis doesn't delete keys but returns an error for write commands. For a server used only as a cache, the docs suggest allkeys-lru as a good default.
noeviction
- What gets evicted
- nothing: writes get an error (the default)
allkeys-lru
- What gets evicted
- least recently used keys — a common cache choice
allkeys-lfu
- What gets evicted
- least frequently used keys
volatile-lru, volatile-ttl
- What gets evicted
- only keys with a TTL: least recently used or with the shortest time left
allkeys-lrm, volatile-lrm
- What gets evicted
- least recently modified keys (added in Redis 8.6)
Cache-aside from Node.js
Cache-aside (lazy loading) is the most common pattern: the application talks to both the cache and the database itself. Read: look up the key in Redis; if it's there, return it; if not, read from the source, store it in Redis with a TTL and return it. Write: change the data in the source and delete the cache key so the next read picks up fresh data.
- 01Application
- 02Redis
- 03Database
The client is node-redis 6.3, which redis.io recommends for Node.js (ioredis is still supported and documented as the older client; BullMQ from the background jobs lesson runs on it, for example). According to the v5-to-v6 migration guide, version 6 uses RESP3 by default, requires Node.js 20 or later and adds a default command timeout of 5 seconds.
mkdir redis-cache && cd redis-cache
npm init -y
npm pkg set type=module
npm install redis@6.3.0import { createClient } from "redis";
export const redis = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:56391",
// while reconnecting, fail a waiting command after 500 ms instead of the default 5 s
commandOptions: { timeout: 500 },
});
redis.on("error", (err) => console.error("redis error:", err.code ?? err.name));
await redis.connect();// Stand-in for a slow database: in a real project this is SQL or an external API
const services = [
{ id: 1, title: "Haircut", priceKzt: 8000 },
{ id: 2, title: "Manicure", priceKzt: 10000 },
{ id: 3, title: "Massage", priceKzt: 15000 },
];
export const stats = { dbCalls: 0 };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function loadServicesFromDb() {
stats.dbCalls++;
await sleep(300); // a heavy query
return structuredClone(services);
}
export async function updatePriceInDb(id, priceKzt) {
await sleep(20);
const service = services.find((s) => s.id === id);
if (!service) throw new Error(`Service ${id} not found`);
service.priceKzt = priceKzt;
}import { redis } from "./redis.js";
// Cache-aside: try the cache; on a miss load from the source and store with a TTL
export async function cached(key, ttlSeconds, load) {
let redisOk = true;
try {
const hit = await redis.get(key);
if (hit !== null) return { data: JSON.parse(hit), source: "cache" };
} catch (err) {
redisOk = false; // Redis is unavailable: serve from the source, don't fail the request
console.warn(`cache read skipped: ${err.constructor.name}`);
}
const data = await load();
if (redisOk) {
await redis
.set(key, JSON.stringify(data), { expiration: { type: "EX", value: ttlSeconds } })
.catch((err) => console.warn(`cache write skipped: ${err.constructor.name}`));
}
return { data, source: "db" };
}
export async function invalidate(...keys) {
await redis.del(keys);
}import { cached, invalidate } from "./cache.js";
import { loadServicesFromDb, updatePriceInDb } from "./db.js";
const SERVICES_KEY = "cache:services:v1";
const SERVICES_TTL = 300; // 5 minutes: the upper bound of staleness if invalidation is missed
export function getServices() {
return cached(SERVICES_KEY, SERVICES_TTL, loadServicesFromDb);
}
export async function changePrice(id, priceKzt) {
await updatePriceInDb(id, priceKzt); // 1. write to the source of truth
await invalidate(SERVICES_KEY); // 2. only then drop the stale copy
}import { redis } from "./redis.js";
import { getServices, changePrice } from "./catalog.js";
import { stats } from "./db.js";
async function timed(label, fn) {
const t0 = performance.now();
const { data, source } = await fn();
const ms = (performance.now() - t0).toFixed(1);
console.log(`${label}: ${source} in ${ms} ms, Haircut = ${data[0].priceKzt}`);
}
await redis.del("cache:services:v1");
await timed("1st read", getServices);
await timed("2nd read", getServices);
console.log("TTL:", await redis.ttl("cache:services:v1"));
await changePrice(1, 9000);
console.log("after invalidation exists:", await redis.exists("cache:services:v1"));
await timed("3rd read", getServices);
await timed("4th read", getServices);
console.log("db calls:", stats.dbCalls);
await redis.close();$ node src/demo-cache.js
1st read: db in 302.5 ms, Haircut = 8000
2nd read: cache in 0.7 ms, Haircut = 8000
TTL: 300
after invalidation exists: 0
3rd read: db in 308.2 ms, Haircut = 9000
4th read: cache in 0.7 ms, Haircut = 9000
db calls: 2Note how the TTL is written: { expiration: { type: "EX", value: ttlSeconds } }. The { EX: 300 } form you'll see in many examples is marked deprecated in the node-redis 6.3 types. The same goes for closing: the type definitions mark quit() as deprecated in favor of close(), which waits for commands already sent, while destroy() drops them immediately.
- Write to the database first, then delete the key. If you delete the key before writing, a concurrent request can put stale data back into the cache.
- Deleting is safer than overwriting: two concurrent updates can write cache values in the wrong order.
- The TTL is a safety net: even if invalidation is missed somewhere, stale data lives no longer than 5 minutes.
- A version in the key (
cache:services:v1) lets you change the data format: new code simply reads a different key.
Checking the fallback: we warmed the cache, stopped the container with docker stop and read again. The client tries to reconnect, the GET command waits to be sent and is rejected with TimeoutError after 500 ms, and the data comes from the database:
warm: db in 305 ms
redis error: Error
redis error: ECONNREFUSED
redis error: ECONNREFUSED
redis error: ECONNREFUSED
cache read skipped: TimeoutError
redis error: ECONNREFUSED
redis stopped: db in 824 msAnother cache-aside problem is a simultaneous miss (cache stampede): when a popular key expires, hundreds of requests hit the database at once. Simple remedies are TTL jitter (exercise below) and a rebuild lock via SET … NX EX; invalidation strategies are covered in depth in the caching article in the architecture track.
Counters and rate limiting
Counters are the most common use after caching: INCR is atomic, so ten copies of an application can increment one key without losing updates. For daily statistics it's convenient to keep one hash per day, increment a service's field with HINCRBY, and set the hash's lifetime once.
A rate limit protects the lead form from spam: no more than 5 submissions per minute from one IP. The fixed-window scheme: a key rl:lead-form:<ip>, INCR on every request and a 60-second TTL on the first request of the window. The INCR docs warn about a race: if the process dies between INCR and EXPIRE, the key stays without an expiry and the client is blocked forever. So the commands go in one MULTI/EXEC block — Redis runs them back to back without serving other clients in between — and EXPIRE with NX doesn't extend the window on every request.
import { redis } from "./redis.js";
// Views per service per day: one hash per day, kept for 8 days
export async function countView(serviceId, day = new Date().toISOString().slice(0, 10)) {
const key = `stats:views:${day}`;
const [views] = await redis
.multi()
.hIncrBy(key, String(serviceId), 1)
.expire(key, 8 * 24 * 3600, "NX")
.exec();
return views;
}
// Fixed window: at most `limit` requests per `windowSec` seconds for one client
export async function rateLimit(clientId, limit, windowSec) {
const key = `rl:lead-form:${clientId}`;
// MULTI/EXEC: INCR and EXPIRE are sent together, the counter can't be left without a TTL
const [count, , ttl] = await redis
.multi()
.incr(key)
.expire(key, windowSec, "NX") // NX: set the TTL only on the first request of the window
.ttl(key)
.exec();
return { allowed: count <= limit, count, retryAfter: count <= limit ? 0 : ttl };
}import { redis } from "./redis.js";
import { countView, rateLimit } from "./counters.js";
await redis.del(["rl:lead-form:203.0.113.7", "rl:lead-form:198.51.100.20", "stats:views:2026-10-12"]);
for (const id of [1, 1, 2, 1, 3]) await countView(id, "2026-10-12");
console.log("views:", await redis.hGetAll("stats:views:2026-10-12"));
console.log("views TTL:", await redis.ttl("stats:views:2026-10-12"));
// The lead form: 5 submissions per 60 seconds per IP (addresses from documentation ranges)
for (let i = 1; i <= 7; i++) {
const r = await rateLimit("203.0.113.7", 5, 60);
console.log(`request ${i}: ${r.allowed ? "200 OK" : `429, retry after ${r.retryAfter}s`} (count ${r.count})`);
}
const other = await rateLimit("198.51.100.20", 5, 60);
console.log(`another IP: ${other.allowed ? "200 OK" : "429"} (count ${other.count})`);
await redis.close();$ node src/demo-limits.js
views: { '1': '3', '2': '1', '3': '1' }
views TTL: 691200
request 1: 200 OK (count 1)
request 2: 200 OK (count 2)
request 3: 200 OK (count 3)
request 4: 200 OK (count 4)
request 5: 200 OK (count 5)
request 6: 429, retry after 60s (count 6)
request 7: 429, retry after 60s (count 7)
another IP: 200 OK (count 1)> TTL rl:lead-form:203.0.113.7
(integer) 60
> HGETALL stats:views:2026-10-12
1) "1"
2) "3"
3) "2"
4) "1"
5) "3"
6) "1"
$ docker exec redis-lab redis-cli --scan --pattern 'rl:*'
rl:lead-form:198.51.100.20
rl:lead-form:203.0.113.7A fixed window is simple but imprecise at the boundary: a client can send 5 requests at the end of one minute and 5 at the start of the next. That's fine for a lead form; paid APIs usually use a sliding window or a token bucket. We checked the reset separately: with a 2-second window the third request was rejected, and after a 2.1-second pause the count started over.
What not to keep in Redis, and common mistakes
- The only copy of important data — orders, payments, leads: with RDB you can lose the last minutes of writes, with AOF and fsync every second up to a second
- Huge objects in a single key: commands run one after another, and a giant
GETdelays other requests - Data without a TTL "just in case" — memory is finite, and the default
noevictionpolicy will start rejecting writes - Personal data you don't need, or without an expiry
- Plain-text secrets and passwords; Redis should be reachable only from the application, with a password or ACL
The rate-limit counter never resets
- Cause
INCRwithoutEXPIRE, orEXPIREoutsideMULTI- Fix
MULTIwithINCRandEXPIRE … NX
The TTL disappeared after updating a key
- Cause
SETwithoutEXoverwrites the key along with its expiry- Fix
- pass the expiry on every write
Users see the old price
- Cause
- the cache wasn't invalidated, or was deleted before the database write
- Fix
- write to the database, then
DEL; TTL as a safety net
WRONGTYPE Operation against a key…
- Cause
- a command of the wrong type, or a key name clash
- Fix
- prefixes in names:
cache:,rl:,stats:
Write errors when memory is full
- Cause
noevictionpolicy andmaxmemoryreached- Fix
- set
maxmemoryandallkeys-lrufor a cache
The site goes down when Redis is unavailable
- Cause
- cache errors aren't handled
- Fix
- a fallback to the source and a short timeout
How Redis compares with document stores and other NoSQL databases is covered in a separate article in this track. The main rule of this lesson: Redis speeds up and offloads your main database, but it isn't responsible for keeping data safe unless you've explicitly configured persistence, backups and replication for that.
Exercises
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.