Invalidation and Stampedes
Delete the keys a write made wrong, and stop fifty simultaneous misses from becoming fifty identical queries.
await redis.del(`v1:user:${id}:profile`) // after the write has committedA cached answer stays correct until someone changes the row behind it. Invalidation is the work of removing the entry at that moment. It is the half of caching that produces bugs, because nothing fails loudly: the service keeps answering, just with the wrong thing.
Bust on write
The rule that holds up: when a write succeeds, delete the keys it made wrong, with await redis.del(`user:${id}:profile`). Delete rather than overwrite. Two writers that each push their own value can arrive in either order and leave the older row cached, while a delete only makes the next reader go and look. Write-through, which updates the row and the cached copy in one step, saves that reader a miss and carries the same ordering risk, so keep it for data with a single writer.
When a change touches everything, such as a new shape for a cached object, bump a version segment in the key. Every key begins v3:, nothing reads v2: again, and the old entries expire on their own.
Stampedes
A popular key expires. Fifty requests miss inside the same second and all fifty run the same slow query. That is a stampede, and it is how a cache turns a busy minute into an outage.
Collapse them inside the process: keep a second map of the keys being computed right now and hand every later caller the same promise. These are the same thing: const p = inFlight.get(key) ?? start(key) is shorthand for let p = inFlight.get(key); if (p === undefined) { p = start(key) }. Across separate processes, let one of them take a short Redis lock and refill while the others wait or serve the old value.
Example
When to use it
- Clear a user's cached profile the moment they save a new display name.
- Stop a hot key's expiry from putting fifty identical queries on the database.
- Change the shape of a cached object without serving the old shape after deploy.
- Keep serving a slow pricing table while one process refreshes it.
More examples
Busting the keys a write made wrong
One write usually makes more than one key wrong: the profile, the summary card, maybe a list the user appears in. Listing them next to the write is the only way they stay in step, which is a reason to keep writes for one table in one module.
import Redis from 'ioredis';
import { Pool } from 'pg';
const redis = new Redis(process.env.REDIS_URL);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function renameUser(id, name) {
await pool.query('UPDATE users SET name = $1 WHERE id = $2', [name, id]);
// Only now, with the write committed, remove every key it invalidates.
// del() takes several keys and resolves to the number it removed.
await redis.del(`v1:user:${id}:profile`, `v1:user:${id}:card`);
}One refill, with the rest reading the stale value
Stale-while-revalidate. The entry outlives its own freshness, so a stampede has something to be served while exactly one process does the work. Worth the extra field for keys that are both expensive and read constantly.
// Store the value with a long TTL plus a soft expiry inside the payload.
// After the soft expiry the entry is still served, but one process refreshes it.
async function getPricing() {
const key = 'v1:pricing';
const raw = await redis.get(key);
const entry = raw === null ? null : JSON.parse(raw);
const softExpired = entry !== null && entry.freshUntil <= nowSeconds();
if (entry !== null && !softExpired) return entry.value;
// SET lock:pricing <id> NX EX 30 succeeds for one process only:
// NX means "only if the key does not exist", and it resolves to null
// for everyone who lost the race.
const won = await redis.set('lock:v1:pricing', 'refresh', 'EX', 30, 'NX');
if (won === null && entry !== null) return entry.value; // serve the old value
const value = await loadPricingFromDb();
const payload = { value, freshUntil: nowSeconds() + 60 };
await redis.set(key, JSON.stringify(payload), 'EX', 600); // hard TTL, longer
return value;
}A version prefix, bumped in one place
Keys built by hand in a dozen files drift, and a stale shape from a previous release reaches code that no longer understands it. One module of key builders plus a version constant turns a risky deploy into a one-line change, and the old entries disappear on their TTL.
// config/cache.js
// Bump this when the cached SHAPE changes, for example after adding a field.
export const CACHE_VERSION = 'v3';
export const keys = {
userProfile: (id) => `${CACHE_VERSION}:user:${id}:profile`,
searchPage: (hash, page) => `${CACHE_VERSION}:search:${hash}:p${page}`,
};
// Every reader and writer builds keys from here, so a deploy that changes
// the shape cannot read a v2 entry written by the previous release.
Discussion