Why cache
A cache trades a little correctness risk for a lot of speed. Reading from Redis (in-memory) takes microseconds; reading from Postgres and computing a result might take tens or hundreds of milliseconds. When the same data is read far more often than it changes, caching is the highest-leverage performance win available.
The catch is in “a little correctness risk.” Every cache introduces the possibility of serving data that’s out of date. Good caching is mostly about managing that, not about the speed — the speed is easy.
The fundamental tradeoff
Phil Karlton’s line — “There are only two hard things in computer science: cache invalidation and naming things” — is a joke that stopped being funny the first time you shipped a cache bug. Every caching decision is really a decision about how much staleness you can tolerate and for how long.
Before caching anything, ask: if this data is 30 seconds out of date, does anything bad happen? For a product’s view count, no. For a user’s account balance shown at checkout, absolutely. The answer determines your whole strategy.
Cache-aside: the default pattern
The most common and most robust pattern. The application manages the cache explicitly:
async function getUser(id) {
const cacheKey = `user:${id}`;
// 1. Try the cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Miss — go to the source of truth
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
// 3. Populate the cache with a TTL, then return
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // 5 min
return user;
}
The flow: check cache → on miss, read database → store in cache → return. It’s called “cache-aside” because the cache sits beside the database and the app coordinates them.
Why it’s the default: if Redis is down, everything still works — you just fall through to the database every time. The cache is an optimization, not a dependency. That failure mode (slower, not broken) is exactly what you want.
TTL: the invalidation you get for free
The EX 300 above sets a time to live — Redis auto-deletes the key after 5 minutes. This is the simplest invalidation strategy and often the only one you need.
The TTL is a direct dial on the staleness tradeoff:
- Short TTL (seconds): fresher data, more database load, less benefit
- Long TTL (hours): more speedup, more staleness, more database relief
There’s no universally right number. Match it to how stale the data can safely be:
await redis.set('trending:posts', data, 'EX', 60); // 1 min — changes fast
await redis.set(`user:${id}:profile`, data, 'EX', 3600); // 1 hr — changes rarely
await redis.set('site:config', data, 'EX', 86400); // 1 day — nearly static
Even a 30-second TTL on a hot query can cut database load by 99% if that query runs thousands of times a minute — because now it runs at most twice a minute. TTL alone gets you surprisingly far.
Explicit invalidation: when TTL isn’t enough
Sometimes stale-for-even-a-moment is unacceptable — a user edits their profile and must see the change immediately. Then you invalidate on write:
async function updateUser(id, changes) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
// Delete the cache so the next read repopulates it fresh
await redis.del(`user:${id}`);
}
Delete, don’t update. Writing the new value into the cache directly seems tidier but invites bugs: if the write to the database and the write to the cache disagree (a race, a partial failure), you’ve cached something that was never true. Deleting is simpler and safer — the next read rebuilds from the source of truth. Do less in the cache, not more.
The invalidation hard part: dependent data
The trouble comes when one write should invalidate many cache entries. A user changes their name — now every cached post preview, comment, and notification showing that name is stale.
Options, none free:
- Cache with short TTLs and accept brief staleness — usually the pragmatic answer.
- Tagged/grouped keys — track which keys depend on a user and delete them together (more machinery, more correctness).
- Don’t denormalize into the cache — cache the user separately and join at read time, so there’s one place to invalidate.
The mistake is caching heavily denormalized blobs and then discovering you can’t invalidate them coherently. If you can’t answer “what do I delete when this changes?”, you’re not ready to cache it.
The thundering herd
A subtle, painful failure. A popular key expires. In the same instant, a thousand requests miss the cache, and all thousand hit the database simultaneously to recompute the same value. The database, sized for the cached load, falls over. The cache was protecting it, and the moment of expiry becomes the moment of collapse.
12:00:00 "homepage" cached, TTL 60s — DB sees ~0 queries for it
12:01:00 key expires
12:01:00 1,000 concurrent requests all miss
12:01:00 1,000 identical queries hit the DB at once → overload
Mitigation 1 — lock so only one request recomputes:
async function getWithLock(key, fetchFn, ttl = 300) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Only the first request to grab the lock recomputes
const lockKey = `lock:${key}`;
const gotLock = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (gotLock) {
try {
const value = await fetchFn();
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
await redis.del(lockKey);
}
}
// Didn't get the lock — wait briefly and read the now-fresh cache
await new Promise(r => setTimeout(r, 50));
return getWithLock(key, fetchFn, ttl);
}
SET ... NX (“set if not exists”) is atomic — exactly one request wins the lock and recomputes; the rest wait and then read the freshly populated value. One database query instead of a thousand.
Mitigation 2 — jittered TTLs so keys don’t all expire at the same instant:
const ttl = 300 + Math.floor(Math.random() * 60); // 300–360s
await redis.set(key, value, 'EX', ttl);
Spreading expiry across a window turns a synchronized stampede into a trickle. Cheap and effective; use it liberally on batches of related keys.
Caching negative results
A gap that quietly kills databases: if getUser(999) returns nothing, nothing gets cached, so every request for that missing user hits the database forever. Attackers (and buggy clients) exploit this by hammering IDs that don’t exist — a cache-penetration attack.
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!user) {
// Cache the "not found" too, with a short TTL
await redis.set(cacheKey, 'NULL', 'EX', 30);
return null;
}
Cache the absence, briefly. A short TTL keeps you from serving “not found” long after the row actually gets created.
Serialization and key design
Keys should be structured and predictable — this is what lets you reason about invalidation:
`user:${id}` // one user
`user:${id}:posts` // that user's posts
`posts:page:${n}:sort:${sort}` // a paginated, sorted list
A namespacing convention (entity:id:attribute) makes it obvious what a key holds and what to delete when it changes. Random or opaque keys are how caches become impossible to invalidate.
Values: JSON.stringify is the pragmatic default. For very high throughput, a compact binary format (MessagePack) is smaller and faster, but don’t reach for it until profiling says JSON is actually your bottleneck — it rarely is.
Memory limits and eviction
Redis holds data in RAM, which is finite. Configure what happens when it fills up:
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
allkeys-lru evicts the least-recently-used keys when memory is full — sensible for a pure cache, since cold data leaves and hot data stays. Other policies suit other uses (volatile-lru only evicts keys with a TTL, useful when Redis also holds persistent data). Without a policy, a full Redis starts rejecting writes, which surfaces as mysterious cache failures under load — exactly when you least want them.
What not to cache
- Data that changes every read — a live counter incremented on each hit gains nothing from caching and adds invalidation cost.
- Data where stale is dangerous and TTL can’t be short enough — permissions and authorization checks; a revoked admin still cached as admin is a security hole, not a performance win.
- Cheap-to-compute data — if the source is already fast, the cache adds a network hop and a consistency risk for no real gain.
- Rarely-read data — caching something read once a day just wastes memory and risks serving it stale.
Caching isn’t free. Each cache is code to maintain, memory to pay for, and a way to be subtly wrong. Add one when the read/change ratio and the latency actually justify it, not reflexively.
A pragmatic starting point
For most applications, this covers the ground:
- Cache-aside as the pattern — the cache is an optimization, never a dependency.
- TTL-based expiry as the primary invalidation — start there, add explicit deletes only where you must.
- Explicit invalidation on write for data that must be fresh immediately.
- Jittered TTLs everywhere, and a recompute lock on your hottest keys.
- Cache negative results briefly to protect against penetration.
- An eviction policy set before you need it.
Start with the simplest thing that tolerates your staleness budget. Add machinery only when a real, measured problem demands it — most caching pain comes from caching too aggressively, then being unable to invalidate coherently.
