How to buildOctober 11, 202611 min read
DatabasesStep 10 of 12

Redis: cache, TTL and counters

In short

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.

TutorialLevel: IntermediateRedisPractice: ~1.5–3 h

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 TTL works, 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 INCR and a form rate limit with MULTI, INCR and EXPIRE NX

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
  1. 01What Redis is and when you need it
  2. 02Running it in Docker and the first commands
  3. 03Hashes, sets and lists in brief
  4. 04TTL and eviction
  5. 05Cache-aside from Node.js
  6. 06Counters and rate limiting
  7. 07What not to keep in Redis, and common mistakes
  8. 08Exercises

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 INCR with 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
SET with EX — 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.

bash
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
Starting Redis 8.10 and an interactive redis-cli inside the container; --rm removes the container once it stops
terminal
$ docker exec redis-lab redis-cli INFO server | grep redis_version
redis_version:8.10.2
$ docker exec redis-lab redis-cli PING
PONG
Real output: the server version and the PING reply

A 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.

terminal
> 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)
A real redis-cli session (output with --no-raw): strings, INCR and the error when incrementing a non-numeric string

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.

terminal
> 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 value
The session continues: a service card as a hash, a duplicate-free set of masters, a list of recent leads and a WRONGTYPE error

String

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:

terminal
> 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"
A real session: SET … EX, -1 and -2, a key expiring, INCR keeps the TTL, SET resets it, SET NX as a simple lock
  • INCR, HSET and LPUSH change the value but leave the TTL alone; SET, GETSET and DEL overwrite the key and clear the expiry — as the EXPIRE docs describe.
  • EXPIRE with NX sets an expiry only if there isn't one; XX only if there is; GT and LT only if the new expiry is greater or less than the current one.
  • SET … NX EX 30 writes 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.

  1. 01Application
  2. 02Redis
  3. 03Database
On a miss the application goes to the database itself and puts the result into Redis

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.

bash
mkdir redis-cache && cd redis-cache
npm init -y
npm pkg set type=module
npm install redis@6.3.0
An ES module project with node-redis 6.3.0
src/redis.js
import { 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();
One connection per application; connection errors are logged instead of crashing the process
src/db.js
// 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;
}
A simulated slow database: 300 ms per query plus a call counter
src/cache.js
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);
}
Cache-aside: on a miss go to the source; if Redis is down, go to the source too, without an error for the user
src/catalog.js
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
}
The service catalog: reads go through the cache; a price change writes to the database, then deletes the key
src/demo-cache.js
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();
Scenario: two reads, a price change, two more reads
terminal
$ 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: 2
Real output: a miss takes about 300 ms, a hit under 1 ms; after invalidation — the fresh price and one more database call

Note 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:

terminal
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 ms
Real output with Redis stopped: the response is slower, but the user still gets the data

Another 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.

src/counters.js
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 };
}
A daily view counter and a fixed-window rate limit
src/demo-limits.js
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();
Five views and seven form submissions from one address (IPs from documentation ranges)
terminal
$ 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)
Real output: HGETALL in node-redis 6 returns a plain object; requests 6 and 7 got 429
terminal
> 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.7
What's left in Redis after the scenario: the window expires on its own; keys found with --scan

A 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 GET delays other requests
  • Data without a TTL "just in case" — memory is finite, and the default noeviction policy 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
INCR without EXPIRE, or EXPIRE outside MULTI
Fix
MULTI with INCR and EXPIRE … NX

The TTL disappeared after updating a key

Cause
SET without EX overwrites 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
noeviction policy and maxmemory reached
Fix
set maxmemory and allkeys-lru for 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.