A shop runs a promotion, traffic jumps several times over, and the product list page starts to crawl. You look closer and see that every visitor makes the database run the same heavy query, over and over, with the same result. The database is the first thing to fall over.
The usual cure is to remember the result for a while so the next visitor gets it instantly. That memory is called a cache, and Redis is where a lot of sites keep it. The idea is easy. Getting it right has a few traps, and this post walks through each one.

Picture a notebook on your desk
Imagine you answer price questions for customers. The warehouse (the database) is at the far end of the street. Each time someone asks, you run over there. Instead, you keep a notebook on your desk. A customer asks, you check the notebook first. If the answer is there, you give it. If not, you run to the warehouse, write the result in the notebook, then answer.
That is the pattern called cache-aside: the app manages the notebook, and the warehouse knows nothing about it. Each line has an expiry (a TTL, time to live), and an expired line counts as missing. When the real data changes, you update the warehouse first, then cross out the matching line. I recommend crossing out rather than rewriting the line, because two updates at the same moment can leave the notebook disagreeing with the warehouse.
Three places where it goes wrong
Everything expires at once. If thousands of lines were written at 9:00 with a one-hour life, they all expire at 10:00 sharp and customers flood the warehouse. The fix is to add a little randomness to each line's lifetime (called jitter).
Crossing out the wrong lines, or missing some. One product list can sit in dozens of lines (each page, each filter, each language). Hunting them down one by one is easy to get wrong. A tidy trick is to put a version number in each line's name. When the data changes, raise the version, and every old line becomes an orphan that quietly expires.
The whole crowd runs to the warehouse. A popular line just expired, 500 customers ask at that moment, and 500 trips go to the warehouse for the same answer. This is a cache stampede. The cure is to let one person go while the others wait for that person's note (using a lock token in Redis), or to let people answer with the old version while one person fetches the new one.

How to do it
- Pick the slowest query that repeats the most. Do not cache everything.
- Set the lifetime by asking "how stale can this number be before the user minds?": tens of seconds for counters, a few minutes for product lists, hours for data that rarely changes. Add a little randomness.
- Start every key with a prefix that belongs to your app, for example
shop.example.com:. - When writing data: write to the database first, then delete or bump the cache.
- For heavily requested keys, add stampede protection.
- If Redis has trouble, the API must still answer, only slower.
Point 3 matters most when several sites share one Redis, as on a VPS that carries a few sites. Two sites that both use the key option:home read and overwrite each other's data. I tried it: two clients with the same prefix, A writes A, B writes B, and A reads back B. With different prefixes each side reads its own data. And never run FLUSHALL on a shared Redis, because it wipes every site's cache. To clean one site, scan by prefix with SCAN and then UNLINK. I deleted 6 keys of site A and site B's keys stayed intact.
For readers who write code
I reran these on a real Redis 8, Node 20 and ioredis. The tests used their own prefix and were cleaned up afterwards.
The Redis client is one shared copy (the globalThis pattern from One Shared Connection for the Whole App: Using Singleton Properly) with keyPrefix set so every key gets the app's prefix. The cache-aside function with jitter:
const jitter = (ttl: number, ratio = 0.1) => Math.round(ttl + Math.random() * ttl * ratio);
export async function cacheAside<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T> {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit) as T;
const value = await loader();
await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
return value;
}
With a fake loader that takes 200 ms, the first call took about 202 ms, the next read from cache took about 0.3 ms, and the loader ran once. A 60-second life showed 64 seconds left after jitter. Those numbers come from my machine with a fake loader, so do not treat them as figures for any real query.
Versioned keys:
export async function versionedKey(group: string, ...parts: (string | number)[]): Promise<string> {
const v = (await redis.get(`ver:${group}`)) ?? "0"; // default "0", not "1"!
return `${group}:v${v}:${parts.join(":")}`;
}
export const bumpVersion = (group: string) => redis.incr(`ver:${group}`);
The "0" deserves attention. My first draft had ?? "1", but INCR on a missing key returns 1, so after bumping, the key name was still v1, the same as before, and the cache never refreshed. With "0" the name moves from products:v0:list:page1 to products:v1:list:page1, and the old key sits there until it expires. That is why every versioned key needs a lifetime.
Stampede protection with a distributed lock:
import { randomUUID } from "node:crypto";
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function cacheLocked<T>(key: string, ttlSec: number, loader: () => Promise<T>, lockMs = 10_000, waitMs = 3_000): Promise<T> {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit) as T;
const lockKey = `lock:${key}`;
const token = randomUUID();
if ((await redis.set(lockKey, token, "PX", lockMs, "NX")) === "OK") {
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
return value;
} finally {
await redis.eval(RELEASE, 1, lockKey, token); // release only our own lock
}
}
const deadline = Date.now() + waitMs; // lost the race: wait for the winner to fill the cache
while (Date.now() < deadline) {
await sleep(25);
const v = await redis.get(key);
if (v !== null) return JSON.parse(v) as T;
}
return loader(); // waited too long, do it ourselves
}
SET ... NX PX means "set only if nobody has, with an expiry", done in one step, so exactly one request wins the lock. The lock expires so a crashed process cannot keep it forever. When releasing, the Lua snippet checks the token so you never delete someone else's lock (releasing with a wrong token returns 0, the right token returns 1).
Result: 50 simultaneous requests on a cold key made cacheAside call the loader 50 times, and cacheLocked exactly once. I tried the same idea in PHP with phpredis: 8 processes at once, the version without a lock hit the database 8 times, the locked one once. phpredis has one trap: once you set a prefix with OPT_PREFIX, the library adds the prefix itself to keys passed to eval. If you add it by hand as well, the key gets the prefix twice and the lock is never released.
Another way is to serve the old copy while refreshing quietly in the background (stale-while-revalidate). With a "fresh" window of 1 second, the cold call took 200 ms, a fresh call 0.2 ms, and once stale it still returned the old copy in 0.5 ms, with the next call already seeing the new one. It suits list pages and stats where a few seconds of staleness is fine.
Mistakes I keep seeing
The first is letting a dead cache kill the API. A broken cache should make the API slower, not broken. Wrap cache reads and writes in try/catch and treat an error as a miss. I pointed the client at a port with no Redis and the wrapper still returned the right data from the loader.
The second: a lock lifetime shorter than the loader's run time. The lock expires while the first person is still working, a second person walks in, and you are back to the crowd at the warehouse. Make the lock lifetime longer than the worst-case run time.
The third is the order of deletion: clearing the cache before writing the database. A request that sneaks in between reloads the old data into the cache. The right order is database first.
What to do next
Take your single slowest query, wrap it in cacheAside with a short lifetime, and measure again. If that query is still so slow that every miss hurts, fix it first; Slow Query? How to Read EXPLAIN and Put Indexes in the Right Place starts there. And if your cache sits behind a public API, Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits covers the interface and rate limits.



