Cache Read Strategies
A read strategy decides who does the work on a cache miss — the application (cache-aside) or the cache library itself (read-through) — and that single choice determines where your data-fetch logic lives, how a cache outage propagates, and which staleness races you are exposed to. Both strategies produce the same steady-state behaviour on a hit (return from cache, skip the database); they differ only on the miss path and in who owns it.
The mechanism, side by side
On a hit, both are identical: the caller asks the cache, the cache returns the value, the database is never touched. The interesting path is the miss.
- Cache-aside (read-aside / lazy loading): the application code is the orchestrator. It calls the cache, sees a miss, calls the database itself, then writes the value back into the cache. The cache is a dumb key-value box that knows nothing about your database.
- Read-through: the application talks only to the cache. The cache is configured with a loader function (or provider) that knows how to fetch from the database. On a miss the cache invokes the loader, populates itself, and returns the value. The application never sees the miss.
The consequence: with read-through, a single bug in the loader is fixed in one place; with cache-aside, every call site that reads that key must repeat the miss-then-load-then-populate dance (or funnel through a shared helper).
A worked trace with real numbers
Assume a Redis lookup takes 1 ms, a Postgres row fetch takes 40 ms, TTL is 300 s, and we serve user:42. Watch the same three requests under cache-aside.
| t | Request | Cache | What the app does | Latency |
|---|---|---|---|---|
| 0 ms | GET user:42 | miss | Redis GET (1) → miss → Postgres (40) → Redis SET (1) | ~42 ms |
| 50 ms | GET user:42 | hit | Redis GET (1) → return | ~1 ms |
| 120 ms | GET user:42 | hit | Redis GET (1) → return | ~1 ms |
The first request pays the full 42 ms penalty (this is why a cold cache is slower than no cache — an extra 1 ms Redis round-trip on top of the DB read). The next two ride at 1 ms, a 40× speedup. Under read-through the numbers are identical; the only difference is that steps 2–4 happen inside the cache client, so your application code just sees cache.get("user:42") return in 42 ms then 1 ms.
Now the payoff of caching: if 95 of every 100 requests hit, average latency is 0.95×1 + 0.05×42 = ~3 ms instead of a flat 40 ms — and, more importantly, 95% of the read load never reaches Postgres.
Cache-aside in code (and why the naive version is wrong)
The obvious implementation has a correctness bug on the write path — it updates the DB and then the cache non-atomically, which loses the point of caching under concurrency.
// NAIVE — has a stale-write race
func GetUser(id string) (User, error) {
if v, ok := cache.Get("user:" + id); ok {
return v.(User), nil
}
u, err := db.LoadUser(id) // cache miss → app loads
if err != nil { return User{}, err }
cache.Set("user:"+id, u, 300) // populate
return u, nil
}
// Elsewhere, a writer:
func UpdateUser(u User) error {
db.SaveUser(u) // 1) write DB
cache.Set("user:"+u.ID, u, 300)// 2) write cache ← RACE
}Why it is wrong: interleave a reader and this writer. Reader misses and loads the old row (v1) at step A. Writer commits v2 to the DB and does cache.Set(v2) at step B. Then the slow reader finally runs its cache.Set(v1) — overwriting v2 with the stale v1, which now lives until the TTL expires (up to 300 s of serving stale data). The fix is to invalidate, not update, the cache on writes, and let the next read repopulate:
func UpdateUser(u User) error {
if err := db.SaveUser(u); err != nil { return err }
cache.Delete("user:" + u.ID) // invalidate; next read reloads v2
return nil
}This does not fully eliminate the race (a read that loads v1 and sets it after the delete can still resurrect a stale value), but it shrinks the window dramatically and is the standard cache-aside write policy. Fully closing it needs versioned keys or a short lock — see the stampede/invalidation lesson.
Pitfalls
- Stale-write race (the classic). Concurrent read-miss and write can leave a stale value pinned in cache until TTL. Invalidate-on-write instead of update-on-write; use short TTLs as a backstop.
- No write path in read-through. Read-through only governs reads. It says nothing about how writes propagate, so you must pair it with write-through or write-behind, or you will serve stale reads after every update.
- Thundering herd on a cold/expired hot key. When a popular key expires, thousands of concurrent misses all hit the DB at once. This bites both strategies; mitigate with request coalescing (single-flight), a mutex/lease, or probabilistic early refresh.
- Caching miss results / negative lookups. If the DB returns "not found", naive code doesn't cache that, so every request for a nonexistent key becomes a DB hit — a cheap DoS vector. Cache negatives with a short TTL.
- Cache outage takes down the DB (read-through risk). If read-through is on the hard request path and the cache dies, all traffic stampedes the DB and can topple it. Cache-aside degrades more gracefully because the app can fall back to the DB deliberately.
- TTL vs freshness. Both strategies serve data up to TTL seconds stale. If you need read-your-writes, invalidate on write and/or drop the TTL.
When to use it / when NOT to
Signals that point to cache-aside: read-heavy, tolerant of eventual consistency, and you want the cache to be optional infrastructure — if it fails, the app still works by hitting the DB. You also want per-key control (different TTLs, selective caching, conditional population). This is why Redis + application code is the default at most companies.
Signals that point to read-through: many call sites read the same entities and you want the load logic centralized in one place (DRY, testable, uniform); you are using a caching library/provider that supports a loader (e.g. Caffeine's LoadingCache in Java, Ehcache, a Guava CacheLoader, or a managed read-through tier). You accept that the cache is now on the critical path.
Trade-offs vs the named alternative
- Cache-aside vs read-through: cache-aside gains failure isolation (cache down ≠ system down) and flexibility, at the cost of duplicated miss-handling logic scattered across the codebase and easy-to-get-wrong write ordering. Read-through gains a single, reusable data-access path and simpler application code, at the cost of tighter coupling (the cache must know your DB), a hard dependency on the cache being up, and less per-call control.
- Both vs no cache: you gain a 40× hit-path speedup and DB-load shedding, but pay with an extra network hop on misses, memory cost, and a permanent staleness/consistency tax you must manage.
Choose cache-aside when resilience and fine-grained control matter more than code tidiness — the common web-backend case. Prefer read-through when the same entity is read from many places and you want one authoritative, well-tested load path, and you can guarantee the cache tier's availability.
Takeaways
- The whole distinction is who fetches on a miss: the app (cache-aside) or the cache's loader (read-through). Hits are identical.
- Cache-aside's superpower is graceful degradation — it can fall back to the DB when the cache is down; read-through's superpower is centralized, DRY load logic.
- On writes, invalidate, don't update, to avoid pinning stale values; read strategies say nothing about writes, so pair them with a write policy.
- Watch the shared failure mode both share — thundering herd when a hot key expires — and mitigate with coalescing or leases.
Sources: Martin Fowler, Cache-Aside
and enterprise caching patterns; Microsoft Azure Architecture Center, Cache-Aside pattern; AWS caching best-practices (ElastiCache); the Caffeine and Ehcache documentation for read-through/LoadingCache semantics; and standard treatments in Alex Xu, System Design Interview
. Worked latencies are representative Redis/Postgres figures. Re-authored and deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Cache Read Strategies? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Cache Read Strategies** (System Design) and want to truly understand it. Explain Cache Read Strategies from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Cache Read Strategies** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Cache Read Strategies** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Cache Read Strategies** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.