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.
127.0.0.1:6379> INFO threads# Threadsio_thread_0:clients=1,reads=375,writes=368127.0.0.1:6379> HSET product:1 name "red running shoes" price 59(integer) 2127.0.0.1:6379> HSET product:2 name "blue trail shoes" price 89(integer) 2127.0.0.1:6379> FT.CREATE idx:products ON HASH PREFIX 1 product: SCHEMA name TEXT price NUMERICOK127.0.0.1:6379> FT.SEARCH idx:products "@name:shoes @price:[50 60]" RETURN 1 name1) (integer) 12) "product:1"3) 1) "name" 2) "red running shoes"127.0.0.1:6379> FT.DROPINDEX idx:productsOK// @runimport { 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 -> 1INCR, 100 concurrent increments -> 100One 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.
Patterns in this module
Section titled “Patterns in this module”| Pattern | Core commands | The trap this module proves |
|---|---|---|
| Caching (cache-aside) | GET, SET ... EX, DEL | SET NX does not stop a stampede: 50 concurrent misses = 50 DB queries |
| Rate limiting | INCR, EXPIRE ... NX, ZSET, Lua | INCR then EXPIRE as two calls can leave a counter that never resets |
| Session store | hash, EXPIRE, RENAME | HSET does not extend the TTL |
| Distributed locks | SET NX PX, DELEX IFEQ / Lua | Plain DEL releases the next holder’s lock |
Two more capabilities in Redis 8
Section titled “Two more capabilities in Redis 8”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.
Reading AI-generated code
Section titled “Reading AI-generated code”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 -> 100For anything more involved than a counter (check a value, then decide), use WATCH, a Lua script or a SET ... NX-style single command.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Redis for these patterns | Sub-millisecond, shared across servers, built-in TTL | Data lives in memory; you must design for loss and for eviction |
| Single command / script atomicity | No locks, no races inside the unit | Anything spanning several commands needs MULTI, WATCH or Lua |
| One shared instance for cache, sessions, limits and locks | Fewer things to run | A noisy tenant (a big key, a stampede) hurts all patterns; separate instances or key eviction settings if that matters |
| Search and vector features built in | No extra service to install | They share the same memory and CPU as your patterns |
Common Misconceptions
Section titled “Common Misconceptions”- “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.CREATEindexes hashes and JSON in Redis 8 (a small demo above).