All articles
Design PatternsSystem DesignPerformance12 min read

Cache-Aside: The Default Caching Strategy and Its Sharp Edges

Read from cache, miss to the database, populate, return. Simple — until stampedes, stale reads and invalidation races arrive. Cache-aside compared to read-through and write-behind, with TTL and consistency guidance.

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

RequestRedisGET keyhitreturn ⚡ ~1msmissDatabaseSET key, TTLthen returnThe application owns the cache. If Redis disappears, everything still works — slower.
Only requested data is ever cached, so the cache stays proportional to real traffic.
  1. Read the key from the cache.
  2. Hit → return it.
  3. Miss → read the database, write the value into the cache with a TTL, return it.
  4. On write → update the database, then delete the key.

Cache-aside vs read-through vs write-behind

StrategyWho loads the dataFailure behaviourBest for
Cache-asideApplicationCache down → slow but correctMost read-heavy workloads
Read-throughCache library/providerCache down → reads failUniform access patterns
Write-throughWrite path fills cacheSlower writes, warm cacheRead-after-write heavy
Write-behindCache flushes async to DBData loss windowExtreme write throughput
Refresh-aheadBackground refresh before expiryWasted refreshesPredictable hot keys
Why cache-aside wins by default
It is the only one where the cache is strictly an optimisation. Redis being unavailable turns into a latency incident rather than an outage — provided you actually wrapped the cache call in a try/catch and a short timeout.

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
The classic stale-write race, which deletion avoids.

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.

  1. Request coalescing — the first miss takes a short lock; the others wait briefly and then read the freshly populated key.
  2. Jittered TTLsttl = base ± rand(10%) so keys created together do not expire together.
  3. Probabilistic early expiry — refresh a fraction of requests just before the TTL, spreading the reload.
  4. 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
}
Cache-aside with a lock, jittered TTL and negative caching.

Choosing TTLs

DataTTLReasoning
Country / currency list24 hoursEffectively static
Product catalogue entry5–15 minChanges rarely, invalidated on write
Inventory count5–30 sWrong quickly, but reads dominate
Personalised feed30–120 sFreshness is a product decision
Account balanceDo not cacheWrong 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.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free