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 DEL from the primary, so a read on a replica can still return an expired key.
  • SET ... NX EX sets a key and a TTL in one atomic step; doing SET then EXPIRE separately 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-policyEvictsRight for
noevictionNothing — writes fail once fullQueues, locks, anything that must not vanish
allkeys-lruLeast recently used keysA pure cache
allkeys-lfuLeast frequently used keysCaches with a stable hot set
volatile-lruLeast recently used keys that have a TTLMixed use where only cache keys have TTLs
volatile-ttlKeys closest to expiryWhen 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-lru cache 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.

Data types and the commands that matter Pub/sub, queues and persistence

Last refreshed 2026-09-18.