Four Ways to Wire a Cache to Your Source of Truth
Caching Strategies
Cache-aside, read-through, write-through, and write-back — where caches live, what hit ratio means, and a working cache-aside implementation with ioredis.
What you'll learn
- Compare cache-aside, read-through, write-through, and write-back
- Reason about cache placement and hit ratio
- Implement cache-aside with TTL using ioredis
A cache trades a little memory for a lot of speed by keeping a copy of expensive data somewhere fast. You already know the why from the latency lesson — RAM is ~100,000× faster than a disk seek. This lesson is about the how: the handful of patterns for wiring a cache to your source of truth, and the consequences of each. Get the pattern right and you serve most reads from memory; get it wrong and you serve stale data, or stampede your database on every miss.
Where caches live
“Cache” isn’t one thing — it’s a layer that appears at every level of the stack, each catching what the layer above missed:
- Client — the browser’s HTTP cache (driven by the
Cache-Controlheaders from the last lesson). - CDN — the edge cache, also header-driven.
- App — an in-process cache (fastest, but per-instance and not shared — the statelessness caveat applies).
- Shared cache — Redis/Memcached, reachable by every instance. This is the workhorse for dynamic data, and where the strategies below live.
- Database — even your DB has its own buffer cache in RAM.
The strategies that follow apply mainly to the shared (Redis) cache in front of your database.
The four strategies
The patterns differ on two axes: who reads/writes the cache (your app, or the cache itself), and when writes hit the database (immediately, or later).
1. Cache-aside (lazy loading)
The most common pattern, and the one to reach for by default. Your application manages the cache explicitly: on a read, check the cache; on a miss, load from the DB and populate the cache. The cache only ever holds data that’s actually been requested — hence “lazy.”
Pros: simple, resilient (if the cache is down, you fall through to the DB), and only caches what’s used. Con: the first request for any key always misses (a “cold” cache), and you must remember to invalidate on writes.
2. Read-through
Same read flow, but the cache library does the DB load on a miss, not your app. Your code only ever talks to the cache; the cache knows how to fetch from the backing store. It hides the miss-handling but requires a cache that supports it (or a wrapper you build). Functionally similar to cache-aside, with the load logic living in the cache layer instead of your handlers.
3. Write-through
On a write, you write to the cache and the database synchronously, in the same operation, before returning. The cache is always consistent with the DB, so reads are never stale. The cost is write latency — every write pays for both stores — and you cache data that may never be read.
4. Write-back (write-behind)
On a write, you write to the cache only and return immediately; the cache flushes to the DB asynchronously later (batched). Writes are blazing fast and the DB sees far fewer, batched writes. The danger: if the cache dies before flushing, those writes are lost. Use it only where some data loss is tolerable (view counts, metrics) or paired with durability guarantees.
| Strategy | Who loads on miss | When DB is written | Read staleness | Risk |
|---|---|---|---|---|
| Cache-aside | App | On write (you invalidate) | Possible if you forget to invalidate | Cold misses |
| Read-through | Cache | On write | Same as cache-aside | Needs supporting cache |
| Write-through | App/Cache | Synchronously, every write | None | Slower writes |
| Write-back | App/Cache | Asynchronously, batched | None for cache reads | Data loss on cache failure |
Hit ratio: the number that decides if it’s worth it
A cache’s value is summarized by its hit ratio — the fraction of reads served from cache rather than the backing store:
hit ratio = hits / (hits + misses)
A 95% hit ratio means only 1 in 20 reads touches the database; the other 19 are served from RAM. The relationship is non-linear and unforgiving at the top: going from 90% to 99% cuts your database read load by 10× (from 10 misses per 100 to 1). Conversely, a cache with a 50% hit ratio is barely earning its keep. Always measure it — a cache you can’t see the hit ratio of is a cache you can’t reason about.
The JavaScript angle: cache-aside with ioredis
Here’s cache-aside implemented for real with ioredis, including the TTL and the
invalidate-on-write that the pattern requires:
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_SECONDS = 300; // expire after 5 minutes
const key = (id) => `user:${id}`;
// READ: check cache → on miss, load DB → populate cache.
async function getUser(id) {
const cached = await redis.get(key(id));
if (cached !== null) {
return JSON.parse(cached); // cache hit
}
const user = await db.users.findById(id); // cache miss → source of truth
if (user) {
// SET with EX: store JSON and let Redis expire it after the TTL.
await redis.set(key(id), JSON.stringify(user), 'EX', TTL_SECONDS);
}
return user;
}
// WRITE: update the DB, then invalidate the cached copy.
// Deleting (rather than rewriting) is simplest and avoids races —
// the next read re-populates from the fresh DB row.
async function updateUser(id, changes) {
const user = await db.users.update(id, changes);
await redis.del(key(id)); // invalidate
return user;
} Two design choices worth calling out. First, the TTL is your safety net: even if you forget to invalidate somewhere, stale data self-corrects within five minutes. Second, we delete on write rather than rewriting the cache — deleting is race-free (the next read repopulates atomically), whereas rewriting opens a window where a concurrent read can restore a stale value.
These patterns assume your cache has room. But memory is finite — when it fills up, something has to be thrown out. Eviction (and the stampedes that happen on a miss) is the next lesson.