Caching (Cache-aside)
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; the script output is from my run through Docker’s port forward, so your millisecond values will differ. The “database” in the script is a fake that sleeps 50 ms per query and counts how often it is called.
The cache-aside pattern (also called lazy loading) keeps your application in control of what goes into the cache and when. Redis sits in front of your database: if the data is there, skip the database. If not, fetch it and populate the cache for next time.
How cache-aside works
Section titled “How cache-aside works”flowchart TD
A["App needs cache:user:42"] --> B["GET cache:user:42"]
B --> C{"Cache hit?"}
C -->|"Yes"| D["Return cached value"]
C -->|"No (nil)"| E["Query DB"]
E --> F["SET cache:user:42 result EX 300"]
F --> G["Return value"] The application owns both reads and writes to the cache. Nothing is preloaded; entries are created on demand and expire through the TTL.
Reading and writing a cache entry
Section titled “Reading and writing a cache entry”127.0.0.1:6379> GET cache:user:42(nil)127.0.0.1:6379> SET cache:user:42 "{\"name\":\"Ada\",\"email\":\"[email protected]\"}" EX 300OK127.0.0.1:6379> TTL cache:user:42(integer) 300127.0.0.1:6379> GET cache:user:42"{\"name\":\"Ada\",\"email\":\"[email protected]\"}"127.0.0.1:6379> SET cache:user:42 "{\"name\":\"Ada updated\"}" EX 300OK127.0.0.1:6379> DEL cache:user:42(integer) 1127.0.0.1:6379> GET cache:user:42(nil)127.0.0.1:6379> SET cache:big "x" EX 300OK127.0.0.1:6379> UNLINK cache:big(integer) 1EX 300 sets a 300-second TTL. TTL reads 300 straight after the write; a moment later it shows 299 because TTL rounds to the nearest second (PTTL gives milliseconds). When the source of truth changes, delete the key so the next read reloads it. UNLINK does the same as DEL but frees the memory in a background thread, which matters for a large cached value (a big JSON blob or a hash with thousands of fields); for small entries the two are equivalent.
GET cache:user:42
SET cache:user:42 "{\"name\":\"Ada\",\"email\":\"[email protected]\"}" EX 300
TTL cache:user:42
GET cache:user:42Cache-aside in ioredis, with a real stampede
Section titled “Cache-aside in ioredis, with a real stampede”The script below is the whole lesson in one file: plain cache-aside with a jittered TTL, then 50 concurrent requests for a key that has just expired, then three fixes. Every number in the output comes from running it.
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// A fake database: 50 ms per query, and we count how many queries reach it.let dbCalls = 0;async function loadUser(id: number): Promise<{ id: number; name: string }> { dbCalls++; await sleep(50); return { id, name: `user-${id}` };}
const ttl = (base: number) => base + Math.floor(Math.random() * base * 0.1); // +0..10 % jitter
// 1) plain cache-asideasync function getUser(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit) return JSON.parse(hit); const user = await loadUser(id); await redis.set(key, JSON.stringify(user), 'EX', ttl(300)); return user;}
await redis.del('b3:cache:user:42');for (const label of ['miss', 'hit', 'hit']) { const t = performance.now(); await getUser(42); console.log(`${label}: ${(performance.now() - t).toFixed(2)} ms`);}
// 2) the stampede: 50 concurrent requests for a key that just expiredawait redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUser(42)));console.log('stampede: 50 concurrent misses ->', dbCalls, 'DB queries');
// 3) SET ... NX alone does NOT make anyone wait: all 50 still query the DBasync function getUserNxOnly(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit) return JSON.parse(hit); const user = await loadUser(id); // every concurrent miss gets here await redis.set(key, JSON.stringify(user), 'EX', 300, 'NX'); // only the WRITE is guarded return user;}await redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUserNxOnly(42)));console.log('SET NX only: 50 concurrent misses ->', dbCalls, 'DB queries');
// 4) fix A: lock (SET NX PX), the winner loads, losers poll the cache keyasync function getUserLocked(id: number) { const key = `b3:cache:user:${id}`; const lock = `${key}:lock`; for (let attempt = 0; attempt < 50; attempt++) { const hit = await redis.get(key); if (hit) return JSON.parse(hit); const won = await redis.set(lock, '1', 'PX', 5000, 'NX'); // 'OK' or null if (won) { try { const user = await loadUser(id); await redis.set(key, JSON.stringify(user), 'EX', 300); return user; } finally { await redis.del(lock); } } await sleep(20); // loser: wait, then look at the cache again } return loadUser(id); // gave up waiting: degrade to the DB}await redis.del('b3:cache:user:42');dbCalls = 0;let t0 = performance.now();await Promise.all(Array.from({ length: 50 }, () => getUserLocked(42)));console.log(`lock + poll: 50 concurrent misses -> ${dbCalls} DB query, ${(performance.now() - t0).toFixed(0)} ms`);
// 5) fix B: single-flight inside one process (share the in-flight promise)const inflight = new Map<string, Promise<unknown>>();function getUserSingleFlight(id: number) { const key = `b3:cache:user:${id}`; let p = inflight.get(key); if (!p) { p = getUser(id).finally(() => inflight.delete(key)); inflight.set(key, p); } return p;}await redis.del('b3:cache:user:42');dbCalls = 0;await Promise.all(Array.from({ length: 50 }, () => getUserSingleFlight(42)));console.log('single-flight: 50 concurrent misses ->', dbCalls, 'DB query (per process)');
// 6) negative caching: remember "not found" briefly so a missing id cannot hammer the DBasync function getUserOrNull(id: number) { const key = `b3:cache:user:${id}`; const hit = await redis.get(key); if (hit !== null) return hit === 'null' ? null : JSON.parse(hit); dbCalls++; await sleep(50); await redis.set(key, 'null', 'EX', 30); // short TTL: the row may appear later return null;}await redis.del('b3:cache:user:999');dbCalls = 0;for (let i = 0; i < 5; i++) await getUserOrNull(999);console.log('negative cache: 5 lookups of a missing id ->', dbCalls, 'DB query');
await redis.del('b3:cache:user:42', 'b3:cache:user:999');await redis.quit();Real output (this Mac):
miss: 54.56 mshit: 1.09 mshit: 1.00 msstampede: 50 concurrent misses -> 50 DB queriesSET NX only: 50 concurrent misses -> 50 DB querieslock + poll: 50 concurrent misses -> 1 DB query, 59 mssingle-flight: 50 concurrent misses -> 1 DB query (per process)negative cache: 5 lookups of a missing id -> 1 DB queryWhat it shows:
- Miss versus hit. The miss pays the (fake) 50 ms database plus the write; a hit is about a millisecond here, and most of that is the Docker port forward. Against a Redis on the same host the hit is well under a millisecond.
- The stampede is real. With nothing protecting the miss path, 50 concurrent requests all found the key empty and all 50 queried the database. Cache-aside without protection turns one expiry into a burst of database load.
SET ... NXalone does not help. The old advice “useSET NXso only the first writer populates the key and the rest wait” is wrong on its face:NXguards only the write. Nobody waits, and all 50 still ran the loader (row 2 of the output). A guard has to sit before the load.- Lock, then poll (fix A). One request wins
SET lock PX 5000 NXand loads; the others sleep 20 ms and look at the cache again. 1 database query for 50 requests, and the whole crowd finished 59 ms after the start. The lock has a TTL so a crashed loader cannot block the key forever, and losers give up after about a second and load themselves rather than hang. Releasing with a plainDELis acceptable here because the worst case is one extra database load; for a lock that guards real work see Distributed Locks. - Single-flight (fix B). Inside one process you do not need Redis for coordination: share the in-flight promise. That collapses concurrent misses per process (N app servers still produce N queries), and it combines well with fix A.
- Negative caching. A lookup for an id that does not exist misses every time and would hit the database every time. Caching a short-lived
nullmarker (here 30 s) makes 5 lookups cost 1 query. Keep its TTL short: the row may be created a moment later. - TTL jitter.
ttl()adds 0-10 % random time so a batch of keys written together does not expire together, which is the other way to create a stampede.
There is a third mitigation, probabilistic early expiration: each reader occasionally refreshes a key before it expires, with a probability that rises as the TTL runs out and as the recompute cost grows, so the refresh is spread out and usually done before anyone sees a miss. The analysis is in Vattani, Chierichetti and Lowenstein, “Optimal Probabilistic Cache Stampede Prevention” (VLDB 2015).
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant is asked to “protect the cache from a stampede”. It adds NX to the write so “only the first request populates the key”:
🐛 Spot the bug
async function getUser(id: number) {const key = `cache:user:${id}`;const hit = await redis.get(key);if (hit) return JSON.parse(hit);const user = await db.loadUser(id); // slow queryawait redis.set(key, JSON.stringify(user), 'EX', 300, 'NX'); // "only the first writer wins"return user;}Show the answer
NX only decides who writes; every request that saw the miss has already run the slow query. In the script’s second stampede row, 50 concurrent requests still produced 50 DB queries. The protection has to come before the load: take a short lock with SET lock 1 PX 5000 NX, let the winner load, and make everyone else re-read the cache.
if (await redis.set(`${key}:lock`, '1', 'PX', 5000, 'NX')) { try { /* load, SET key ... EX 300 */ } finally { await redis.del(`${key}:lock`); }} else { await sleep(20); /* then GET key again, up to a deadline */}With this shape the same 50 requests produced 1 query.
Mental Model
Section titled “Mental Model”A cache-aside cache is a notepad next to the database. Reading checks the notepad first; on a miss you walk to the database, copy the answer onto the notepad and put a “throw away after N minutes” sticker on it. The danger is the moment the sticker expires: everyone who arrives together walks to the database together. The fixes are all about making one person walk while the rest look at the notepad again a moment later.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Cache-aside | The app controls exactly what enters the cache; the DB is never loaded for data nobody asked for | The first request after a miss is slower (query DB + write cache), and readers can see stale data until the TTL or an invalidation |
| Write-through | Cache and DB stay in sync, no stale reads | Every write is slower, since it must write to both before returning |
| Lock + poll on a miss | 1 DB query per expiry across all app servers | Losers wait (about 20 ms polls here), and you need a deadline and a lock TTL |
| In-process single-flight | No extra Redis traffic, trivial to add | Only deduplicates inside one process |
| Probabilistic early expiration | No lock, no waiting; refresh usually happens before expiry | Extra logic per read, needs a recompute-cost estimate |
| Negative caching | A missing id cannot hammer the DB | For the marker’s TTL a newly created row looks missing |
Common Misconceptions
Section titled “Common Misconceptions”- “
SET NXprevents the stampede.” It makes the write conditional; the loads have already happened (50 queries in the run above). - “A cache hit is always correct.” It is correct as of the last load. Invalidate on write and keep a TTL as the safety net.
- “A TTL of 300 s means the key lives exactly 300 s.” It means it is eligible to be removed after 300 s;
TTLrounds to seconds and expired keys are removed lazily plus by a background sweep (Expiration & TTL). - “Caching
nullis a bug.” Uncached misses are the bug for hot missing ids; cache the absence with a short TTL.
Common mistakes
Section titled “Common mistakes”- No TTL on a cache key — data outlives the source of truth and becomes permanently stale.
- No stampede protection on a hot key — one expiry becomes N database queries (50 above).
- Same TTL for a whole batch — add jitter so the batch does not expire in one second.
- Forgetting to invalidate on write — users see stale data until the TTL runs out.
- Unbounded waiting on the lock — always give losers a deadline and give the lock a TTL.
Real-world example
Stack Overflow placed an L1 (local)/L2 (Redis) cache-aside layer in front of SQL Server, at a reported scale of roughly 160 billion Redis operations a month (Stack Overflow: The Architecture – 2016 Edition, Nick Craver, 2016; a ten-year-old figure, quoted with its date).