Expiry and caching patterns
TTL mechanics, eviction policies, cache-aside in practice, and how to stop a cold cache from taking down the database behind it.
How expiry works
SET cache:user:42 '{"name":"Ada"}' EX 300
EXPIRE cache:user:42 300
TTL cache:user:42 # -1 has no TTL, -2 does not exist
PERSIST cache:user:42 # remove the TTL
SET lock:job:9 worker-3 NX EX 30 # atomic: only one worker wins
CONFIG SET maxmemory 4gb
CONFIG SET maxmemory-policy allkeys-lru
INFO memory- Expiry is lazy plus sampled: a key is deleted when accessed, and a background job samples keys with a TTL. An expired key can still occupy memory for a while.
- Replicas do not expire keys on their own — they wait for the
DELfrom the primary, so a read on a replica can still return an expired key. SET ... NX EXsets a key and a TTL in one atomic step; doingSETthenEXPIREseparately leaves a window with no TTL.- A TTL is not a schedule. Redis makes no promise about exactly when memory is reclaimed, only that the key will not be returned afterwards.
| maxmemory-policy | Evicts | Right for |
|---|---|---|
noeviction | Nothing — writes fail once full | Queues, locks, anything that must not vanish |
allkeys-lru | Least recently used keys | A pure cache |
allkeys-lfu | Least frequently used keys | Caches with a stable hot set |
volatile-lru | Least recently used keys that have a TTL | Mixed use where only cache keys have TTLs |
volatile-ttl | Keys closest to expiry | When TTL encodes importance |
Cache-aside, and the stampede
The dominant pattern is cache-aside: read from Redis, and on a miss read from the database, write the value back with a TTL, and return it. Writes update the database and then invalidate the key rather than trying to update both atomically.
async function getUser(id) {
const key = "cache:user:" + id;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const lockKey = "lock:user:" + id;
const gotLock = await redis.set(lockKey, "1", { NX: true, EX: 10 });
if (!gotLock) { // someone else is filling it
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry) return JSON.parse(retry);
}
const row = await db.users.findById(id); // single flight
const ttl = 300 + Math.floor(Math.random() * 60); // jitter
await redis.set(key, JSON.stringify(row ?? null), { EX: ttl });
await redis.del(lockKey).catch(() => {});
return row;
}
async function updateUser(id, patch) {
await db.users.update(id, patch);
await redis.del("cache:user:" + id); // invalidate, do not update
}- Add jitter to TTLs so thousands of keys do not expire in the same second and hit the database together.
- Negative caching (storing a null result briefly) protects the database from repeated misses on keys that do not exist.
- The lock above is best-effort: it reduces duplicate work, it does not guarantee only one loader runs.
- Delete on write rather than update on write — the cached shape and the database shape drift otherwise, and a delete is idempotent.
Cache pitfalls
- Redis is a copy of the data unless you have decided otherwise. Anything that only exists in Redis can be evicted by a policy change.
- Never mix eviction policies by accident: a single instance used as both an
allkeys-lrucache and a job queue will evict the jobs. - Very large values slow every client and can block the event loop for the duration of a transfer; a few kilobytes per value is the comfortable zone.
- Hot keys concentrate all traffic on one shard; add a random suffix to shard them, or keep a local in-process cache in front.
- Serialisation is a compatibility contract. Changing the cached JSON shape without a version prefix means old entries are read as the new type.
💡
Prefix cache keys with a version, for example
cache:v2:user:42. When the stored shape changes you bump the prefix and the old entries expire on their own — no migration script, no flushing.FAQ
Should the TTL be long or short?
Long enough that the hit rate pays for the memory, short enough that stale data is tolerable. If you cannot tolerate staleness at all, shorten it and invalidate explicitly on write; TTLs are a safety net, not a correctness mechanism.
Why did my cache hit rate drop after a deploy?
Usually a key naming change or a different serialisation. Confirm by sampling key names with
SCAN and comparing keyspace_hits and keyspace_misses from INFO stats.Related
Data types and the commands that matter Pub/sub, queues and persistence
Last refreshed 2026-09-18.