Skip to content

Patterns Overview

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is one session in document order, replayed by the course harness.

Redis is not just a cache: it is a data-structure server that solves a class of problems a relational database handles badly, mostly because the answers must be fast, shared between servers, and short-lived. This module walks through four patterns and proves each one against a real server.

Why Redis is fast, and what “atomic” really covers

Section titled “Why Redis is fast, and what “atomic” really covers”

Redis keeps its data in memory, and a typical command finishes in well under a millisecond on the server. Command execution is single-threaded; the process is not. One thread runs your commands one after another, which is why a single command such as INCR can never interleave with another client’s. Redis 8 also runs I/O threads and background threads for tasks such as closing files, AOF fsync and lazy freeing (INFO threads below lists the I/O thread; the earlier wording “Redis is single-threaded” is only true of command execution).

The catch is that atomicity belongs to one command (or one MULTI/EXEC, or one script), not to your application’s read-then-write sequence. The run below is the most important thing in this module.

cli/patterns-index-1.redis
127.0.0.1:6379> INFO threads
# Threads
io_thread_0:clients=1,reads=375,writes=368
127.0.0.1:6379> HSET product:1 name "red running shoes" price 59
(integer) 2
127.0.0.1:6379> HSET product:2 name "blue trail shoes" price 89
(integer) 2
127.0.0.1:6379> FT.CREATE idx:products ON HASH PREFIX 1 product: SCHEMA name TEXT price NUMERIC
OK
127.0.0.1:6379> FT.SEARCH idx:products "@name:shoes @price:[50 60]" RETURN 1 name
1) (integer) 1
2) "product:1"
3) 1) "name"
2) "red running shoes"
127.0.0.1:6379> FT.DROPINDEX idx:products
OK
src/patterns-index-lost-update.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const N = 100;
// Every Redis command is atomic, but GET-then-SET is TWO commands: other clients run in between.
async function badIncrement(key: string) {
const current = Number((await redis.get(key)) ?? 0);
await redis.set(key, String(current + 1));
}
await redis.set('b3:lost', '0');
await Promise.all(Array.from({ length: N }, () => badIncrement('b3:lost')));
console.log(`GET + SET, ${N} concurrent increments -> ${await redis.get('b3:lost')}`);
await redis.set('b3:kept', '0');
await Promise.all(Array.from({ length: N }, () => redis.incr('b3:kept')));
console.log(`INCR, ${N} concurrent increments -> ${await redis.get('b3:kept')}`);
await redis.del('b3:lost', 'b3:kept');
await redis.quit();

Real output (this Mac):

GET + SET, 100 concurrent increments -> 1
INCR, 100 concurrent increments -> 100

One hundred concurrent “read, add one, write back” cycles produced 1: on a single connection all 100 GETs were sent before any SET, so every cycle read 0. Every individual command was atomic and the result is still wrong, because the sequence was not. INCR does the read-modify-write inside one command and got 100. The rest of the module is about closing that gap: SET ... NX for locks, INCR plus EXPIRE NX in a MULTI for counters, and Lua when the decision has several steps (Transactions & Scripting).

Redis also supports TTL: every key can be given an expiration. Expired keys are removed lazily when touched plus by a background sweep, so caches and sessions clean themselves; exact timing is in Expiration & TTL.

PatternCore commandsThe trap this module proves
Caching (cache-aside)GET, SET ... EX, DELSET NX does not stop a stampede: 50 concurrent misses = 50 DB queries
Rate limitingINCR, EXPIRE ... NX, ZSET, LuaINCR then EXPIRE as two calls can leave a counter that never resets
Session storehash, EXPIRE, RENAMEHSET does not extend the TTL
Distributed locksSET NX PX, DELEX IFEQ / LuaPlain DEL releases the next holder’s lock

Redis 8 folded the old add-on modules into the server (What’s new in Redis 8.0), so the run above created a search index on existing hashes with FT.CREATE and queried it with FT.SEARCH (a text term plus a numeric range) without installing anything. The same feature set has vector fields for similarity search, and Redis 8 added vector sets, a dedicated data type that the release notes mark as beta, so use it for experiments, not code you cannot migrate. Neither replaces the four patterns here.

An assistant writes a page-view counter and reasons “Redis is single-threaded, so this is safe”:

🐛 Spot the bug

async function recordView(pageId: string) {
const views = Number((await redis.get(`views:${pageId}`)) ?? 0);
await redis.set(`views:${pageId}`, String(views + 1)); // safe: Redis is single-threaded
}
Show the answer

Each command is atomic, but two commands are not. Concurrent callers all read the same old value and overwrite each other’s updates: in the run above, 100 concurrent increments ended at 1. Use a command that does the read-modify-write server-side:

await redis.incr(`views:${pageId}`); // 100 concurrent calls -> 100

For anything more involved than a counter (check a value, then decide), use WATCH, a Lua script or a SET ... NX-style single command.

Redis is a single clerk at a counter with a very fast hand. The clerk serves one command at a time, so no two commands ever mix on the counter. But if you walk away with the numbers in your head and come back to write, someone else was served in between. Atomicity is per transaction slip (one command, one MULTI, one script), never per customer.

ChoiceBenefitCost
Redis for these patternsSub-millisecond, shared across servers, built-in TTLData lives in memory; you must design for loss and for eviction
Single command / script atomicityNo locks, no races inside the unitAnything spanning several commands needs MULTI, WATCH or Lua
One shared instance for cache, sessions, limits and locksFewer things to runA noisy tenant (a big key, a stampede) hurts all patterns; separate instances or key eviction settings if that matters
Search and vector features built inNo extra service to installThey share the same memory and CPU as your patterns
  • “Redis is single-threaded, so my code is race-free.” Command execution is single-threaded; your read-then-write across commands is not atomic (1 of 100 above).
  • “Redis has no parallelism at all.” It has I/O threads and background threads; only command execution is serialised.
  • “Expired means gone immediately.” Expired keys are removed lazily and by a sweep.
  • “You need a separate search engine for any lookup beyond keys.” FT.CREATE indexes hashes and JSON in Redis 8 (a small demo above).
Why can a single INCR never interleave with another client's command?
100 concurrent clients each do GET n, add one, SET n. The result in the run was:
Which Redis feature lets keys disappear on their own after a timeout?
Which pattern is built on SET key token NX PX ms?
Which of these is atomic?