System Design
Caching Patterns
Cache-aside vs read-through vs write-through. Where each one wins and how cache-aside still owns the world.
Caching patterns
A cache is a contract. It promises to be approximately right, to be fast, and to hide the database from a hot reader. Every cache eventually breaks that contract — the only interesting question is who notices the moment it does, and how loud the failure is when they do.
The reason caching is hard isn't the cache itself. Reading from Redis is genuinely about as simple as it looks. The hard part is that introducing a cache adds a second source of truth to your system, and any code that mutates the underlying data now has a new responsibility it can forget. Most caching outages come down to one sentence: someone wrote to the database and didn't tell the cache.
Cache-aside, and why it won
Cache-aside is the version of caching most teams use, and it deserves to be. The shape is simple: when the application wants data, it checks the cache first. If the cache has it, return it. If the cache misses, fall back to the database, then write the result into the cache before returning. The database doesn't know the cache exists. The cache doesn't know about the database. Every piece of coupling lives in the application code.
That sounds primitive, and it is, and it's exactly why cache-aside keeps winning even at scale. You can introduce it to a running system in an afternoon — deploy Redis, change a handful of read paths, roll out, watch the hit rate climb. You can take it out the same way. No other caching pattern survives that test of incremental adoption.
The hidden cost is that every writer in the system now bears a responsibility nothing in the cache layer reminds them about. When a write happens, the corresponding cached entry has to be invalidated. Forget it once, and a stale read is born. Forget it quietly across a year of feature work, and stale reads become the background hum of customer support tickets.
Cache-aside is so simple teams keep choosing it even at scale. The trade-off is invisible at first: every write has to invalidate something, and nothing in the cache layer reminds the writer to.
The siblings
The other patterns are useful in narrower situations and worth naming so you can recognize them when you see one.
Read-through moves the fallback logic out of the application
and into a cache library. The library — Caffeine in the JVM world,
the cache client in NestJS, the read-through wrappers in some
Redis clients — knows how to call the database on a miss. The app
just calls get(key) and gets a value back, miss or hit. Less
app code, slightly more magic. The library now needs database
credentials, which raises eyebrows in some security reviews.
Write-through writes to the cache AND the database in the same request. The cache is never stale on the path you just wrote. Reads are faster than cache-aside because the entry is already warm. Writes pay the latency of both stores. Used inside storage engines (RocksDB block cache, Postgres buffer pool) more than in application code.
Write-back (or "write-behind") is the dangerous one. Writes go to the cache only and are flushed to the database asynchronously. Writes are blisteringly fast — milliseconds where a write-through would take 20. Durability becomes a story you have to defend in detail: if the cache crashes with unflushed writes in the buffer, those writes are gone. Real systems use this for hot counters and ephemeral analytics; you would not use it for a banking ledger.
| Pattern | App responsibility | Read latency | Write latency | Crash impact |
|---|---|---|---|---|
| Cache-aside | Both reads and invalidation | Cache hit-rate | DB latency | Stale reads (until TTL) |
| Read-through | Reads only | Cache hit-rate | DB latency | Stale reads (until TTL) |
| Write-through | None | Cache hit-rate | DB + cache | Nothing lost |
| Write-back | None | Cache hit-rate | Cache only | Unflushed writes lost |
The two failure modes that catch everyone
Stale reads
A sibling service writes to the database directly without invalidating the cache. The reading service keeps returning yesterday's data. The reading code is fine — there's no bug to find — and the cache reports a 98% hit rate, so it looks healthy. Customer support fields the complaints, and the team spends a week trying to reproduce something that only happens in production.
A team adds a cron job or a background worker that writes straight to the database to fix a one-off issue. Three weeks later, customers report stale data, and nobody can reproduce it in staging — because staging doesn't have the cron job.
The structural fix is change-data-capture: a small service tails the database's write-ahead log, parses each row change into an event, and invalidates the corresponding cache key. Writers never need to know the cache exists. CDC is also a third moving piece to operate, monitor, and explain to whoever is on call.
Stampede
A hot key expires. Twenty concurrent readers all miss at the same moment and race to the database with twenty identical queries. The database briefly handles the full read volume the cache was absorbing. CPU spikes, query latency hits a cliff, and a few of the readers time out — the rest succeed and re-warm the cache, and the dashboard looks fine seconds later. But you've taken a small latency wound, and on bigger keys (or hotter ones) the wound is big enough to take the database down.
The simplest fix is singleflight: only one in-flight request
per key is allowed to hit the database; the rest wait for it to
populate. Go's sync/singleflight, Caffeine's get with
BiFunction, and most Redis clients have a primitive for this.
The fancier fix is probabilistic early refresh: before a TTL
expires, a small fraction of reads refresh the entry, so the
expiry never coincides with a thundering herd.
When a cache hurts
It's tempting to add a cache to everything once you have one running. Two cases where it's the wrong move:
The workload is write-heavy with low read amplification. A write-once-read-once row doesn't benefit from caching — you've added the invalidation cost without absorbing any reads.
The reads need strong consistency. A cache by definition gives approximately-right answers. If the product requires "I just wrote that and now I need to read it back exactly", route those reads to the primary and accept the latency.
After load balancing, caching is almost always the second move a system makes. The third is usually [CONCEPT]replication or [CONCEPT]sharding-strategies, depending on whether you need more read capacity or more write capacity.