The problem it solves
The homepage asks for the same fifty product records on every request. The category tree changes twice a week and is read a hundred times a second. None of that work needs to reach a database that is measured in tens of milliseconds and priced per IOPS.
Cache-aside — also called lazy loading — is the strategy where the application, not the cache, is in charge. It is the default for a reason: it is simple, and it degrades safely.
The cache-aside flow
- Read the key from the cache.
- Hit → return it.
- Miss → read the database, write the value into the cache with a TTL, return it.
- On write → update the database, then delete the key.
Cache-aside vs read-through vs write-behind
| Strategy | Who loads the data | Failure behaviour | Best for |
|---|---|---|---|
| Cache-aside | Application | Cache down → slow but correct | Most read-heavy workloads |
| Read-through | Cache library/provider | Cache down → reads fail | Uniform access patterns |
| Write-through | Write path fills cache | Slower writes, warm cache | Read-after-write heavy |
| Write-behind | Cache flushes async to DB | Data loss window | Extreme write throughput |
| Refresh-ahead | Background refresh before expiry | Wasted refreshes | Predictable hot keys |
Invalidation: delete, do not update
On a write, it is tempting to recompute the value and SET it. Resist. Two concurrent writers can interleave their computations and leave the older value in the cache permanently.
T1 reads v1 from DB T2 writes v2 to DB T2 SETs cache = v2 T1 SETs cache = v1 // cache is now permanently wrong
Deleting is idempotent and self-healing: the next reader repopulates from whatever the database currently holds. Write to the database first, then delete — and accept the tiny window where a concurrent reader can repopulate stale data, bounded by your TTL.
- Delete after commit, not before, or you cache a value that gets rolled back.
- For critical keys, delete twice — immediately and again after a short delay.
- Version keys (
product:123:v7) when a schema change makes old entries wrong.
Stampedes and hot keys
A popular key expires. Five thousand concurrent requests miss simultaneously and all hit the database with the identical query. The database, which was comfortable a second ago, falls over.
- Request coalescing — the first miss takes a short lock; the others wait briefly and then read the freshly populated key.
- Jittered TTLs —
ttl = base ± rand(10%)so keys created together do not expire together. - Probabilistic early expiry — refresh a fraction of requests just before the TTL, spreading the reload.
- Serve stale while revalidating — return the expired value and refresh in the background. Correct for feeds, wrong for balances.
Also guard against cache penetration: repeated lookups for ids that do not exist bypass the cache entirely. Cache the negative result with a short TTL.
Implementation sketch
async function getProduct(id: string): Promise<Product | null> {
const key = `product:${id}:v3`;
const cached = await safeGet(key); // try/catch + 50ms timeout
if (cached === NEGATIVE) return null;
if (cached) return JSON.parse(cached);
const lock = await redis.set(`${key}:lock`, "1", { NX: true, PX: 3000 });
if (!lock) {
await sleep(50);
const retry = await safeGet(key);
if (retry) return JSON.parse(retry); // populated by the winner
}
try {
const row = await db.products.findById(id);
const ttl = row ? jitter(300) : 30; // negative cache is short
await safeSet(key, row ? JSON.stringify(row) : NEGATIVE, ttl);
return row;
} finally {
await redis.del(`${key}:lock`);
}
}
// write path
async function updateProduct(id: string, patch: Patch) {
await db.products.update(id, patch); // source of truth first
await redis.del(`product:${id}:v3`); // then invalidate
}Choosing TTLs
| Data | TTL | Reasoning |
|---|---|---|
| Country / currency list | 24 hours | Effectively static |
| Product catalogue entry | 5–15 min | Changes rarely, invalidated on write |
| Inventory count | 5–30 s | Wrong quickly, but reads dominate |
| Personalised feed | 30–120 s | Freshness is a product decision |
| Account balance | Do not cache | Wrong value is unacceptable |
The TTL is your bound on how wrong you are willing to be. Set it from that question, not from a hit-rate target.
Pitfalls
- Caching without measuring hit rate — a 20% hit rate is added complexity for nothing.
- Storing giant objects; you are paying serialisation and network on every hit.
- No timeout on the cache call, so a slow Redis becomes slower than the database.
- Caching per-user data under a shared key — the classic leak of one user's data to another.
- Treating the cache as a database. It evicts. Anything you cannot recompute must live elsewhere.
Interview framing
“Product reads go through cache-aside in Redis with a jittered 10-minute TTL, negative caching for missing ids, and a short lock to coalesce stampedes on hot keys. Writes commit to PostgreSQL and then delete the key rather than updating it, so concurrent writers cannot leave a stale value behind. Cache errors are swallowed with a 50ms timeout, so Redis being down degrades latency rather than availability.”