Skip to content

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.

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"]
Cache-aside read flow: check Redis, fall back to the DB on a miss, then write back with a TTL

The application owns both reads and writes to the cache. Nothing is preloaded; entries are created on demand and expire through the TTL.

cli/patterns-caching-1.redis
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 300
OK
127.0.0.1:6379> TTL cache:user:42
(integer) 300
127.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 300
OK
127.0.0.1:6379> DEL cache:user:42
(integer) 1
127.0.0.1:6379> GET cache:user:42
(nil)
127.0.0.1:6379> SET cache:big "x" EX 300
OK
127.0.0.1:6379> UNLINK cache:big
(integer) 1

EX 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:42

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

src/patterns-caching-stampede.ts
// @run
import { 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-aside
async 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 expired
await 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 DB
async 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 key
async 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 DB
async 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 ms
hit: 1.09 ms
hit: 1.00 ms
stampede: 50 concurrent misses -> 50 DB queries
SET NX only: 50 concurrent misses -> 50 DB queries
lock + poll: 50 concurrent misses -> 1 DB query, 59 ms
single-flight: 50 concurrent misses -> 1 DB query (per process)
negative cache: 5 lookups of a missing id -> 1 DB query

What 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 ... NX alone does not help. The old advice “use SET NX so only the first writer populates the key and the rest wait” is wrong on its face: NX guards 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 NX and 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 plain DEL is 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 null marker (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).

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 query
await 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.

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.

ChoiceBenefitCost
Cache-asideThe app controls exactly what enters the cache; the DB is never loaded for data nobody asked forThe first request after a miss is slower (query DB + write cache), and readers can see stale data until the TTL or an invalidation
Write-throughCache and DB stay in sync, no stale readsEvery write is slower, since it must write to both before returning
Lock + poll on a miss1 DB query per expiry across all app serversLosers wait (about 20 ms polls here), and you need a deadline and a lock TTL
In-process single-flightNo extra Redis traffic, trivial to addOnly deduplicates inside one process
Probabilistic early expirationNo lock, no waiting; refresh usually happens before expiryExtra logic per read, needs a recompute-cost estimate
Negative cachingA missing id cannot hammer the DBFor the marker’s TTL a newly created row looks missing
  • “SET NX prevents 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; TTL rounds to seconds and expired keys are removed lazily plus by a background sweep (Expiration & TTL).
  • “Caching null is a bug.” Uncached misses are the bug for hot missing ids; cache the absence with a short TTL.
  • 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).

In cache-aside, when does the app write to Redis?
Which command removes a stale cache entry, and frees a large value without blocking?
A popular key expires and 50 requests miss at once. With a plain SET ... EX 300 NX after the load, how many DB queries run?
Which design makes the losers of a cache-fill race wait correctly?
Why cache a null marker for an id that does not exist?
Why add random jitter to cache TTLs?