Caching Strategies

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.

9 min read Level 3/5 #system-design#caching#redis
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:

Caching Strategies — architecture diagram
  • Client — the browser’s HTTP cache (driven by the Cache-Control headers 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 cacheRedis/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.”

Caching Strategies — architecture diagram

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.

StrategyWho loads on missWhen DB is writtenRead stalenessRisk
Cache-asideAppOn write (you invalidate)Possible if you forget to invalidateCold misses
Read-throughCacheOn writeSame as cache-asideNeeds supporting cache
Write-throughApp/CacheSynchronously, every writeNoneSlower writes
Write-backApp/CacheAsynchronously, batchedNone for cache readsData 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:

Cache-aside read + invalidate-on-write script.js
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;
}
▶ Preview: console

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.