CodeOath
← All posts
System Design52 min total · 14 parts

System Design Fundamentals for Interviews: Scalability, Trade-offs, and the Framework Interviewers Actually Grade

Part 5 of 14 · ~5 min

Caching Strategies

A cache trades a small amount of storage — usually fast, expensive, in-memory storage — for a large reduction in latency and load on whatever's behind it (typically a database). The strategy questions are: when does data get written into the cache, and when does it get thrown out?

Cache Population Strategies

StrategyHow it worksTrade-off
Cache-aside (lazy loading)App checks cache first; on a miss, reads from the DB and writes the result into the cache itselfOnly requested data ever gets cached, but the first request for any given key always eats a cache miss
Write-throughEvery write goes to the cache and the DB, synchronously, as one operationCache is never stale, but every write pays the latency cost of both writes
Write-back (write-behind)Write goes to the cache immediately; the DB write happens later, asynchronouslyWrites are fast, but data sitting only in cache is lost if the cache crashes before it's flushed

Cache-aside is by far the most common pattern in practice, precisely because it only spends cache space on data that's actually being requested — a key nobody asks for never occupies a slot. The code shape is always the same:

function get(key):
    value = cache.get(key)
    if value is not None:
        return value              # cache hit
    value = database.get(key)     # cache miss — go to the source of truth
    cache.set(key, value)
    return value

Write-through guarantees the cache is never out of sync with the database, because every write updates both before it's considered done — the cost is that every write now waits on two systems instead of one. Write-back flips that trade: writes return fast because only the cache has to acknowledge them, but there's now a window where the only copy of the freshest data lives in a volatile cache, and a crash in that window loses writes the client already believes succeeded.

TTL and Eviction Policies

A cache can't grow forever, so entries need a way to leave. Two independent mechanisms usually work together: a TTL (time-to-live), after which an entry is considered stale and is dropped or refreshed regardless of how popular it is, and an eviction policy, which decides what to remove specifically when the cache is full and something new needs room.

PolicyEvictsGood fitWeak point
LRU (Least Recently Used)The entry that hasn't been accessed in the longest timeGeneral-purpose — recent access is usually a decent predictor of near-future accessA single burst of one-off scans can evict genuinely hot data it hasn't touched in a while
LFU (Least Frequently Used)The entry with the lowest total access countData with a stable "hot set" that gets accessed far more often than everything elseA once-popular item can stay stuck in the cache long after it's stopped being requested, because its historical count is high

Neither one is universally "better" — LRU adapts faster to a shifting access pattern, LFU is more resistant to a temporary spike of one-off accesses evicting your genuinely hot data. Most production caches (Redis included) default to an LRU-family policy specifically because "recently accessed" tends to be a good enough proxy for "likely to be accessed again soon" across a wide range of real workloads.

CDN Caching for Static Assets

A Content Delivery Network is a geographically distributed layer of caching servers (edge servers / points of presence) that sit physically closer to end users than your origin servers do, and cache content — images, video, JS/CSS bundles, and anything else that doesn't change per-request — at those edge locations. A user in Mumbai requesting a static image ideally never makes a round trip to an origin server in Virginia at all; the nearest edge node already has it cached and serves it directly, which cuts both latency (shorter physical distance) and load on the origin (the edge absorbs the repeat requests entirely).

CDNs are a great fit for content that's identical for every user and changes rarely — static assets are the canonical example — and a poor fit for anything genuinely personalized or that must reflect the very latest write, for the same reason any cache is a poor fit for that kind of data: a cache's entire value proposition rests on serving the same answer to many requests instead of computing it fresh each time.

Cache Invalidation: The Actually Hard Problem

There's an old joke in computer science that there are only two hard problems: cache invalidation and naming things. The reason invalidation earns that reputation is that a cache is, by definition, a second copy of the truth, and the moment the original changes, that copy is stale until something notices and fixes it — and "something notices" is a genuinely hard problem to get right at scale.

The three common approaches, in roughly increasing order of correctness and decreasing order of simplicity:

  • TTL-based expiry — just let it go stale for a bounded window and accept it. Simple, but every read inside that window can be serving old data, and picking the TTL is a direct trade between staleness and cache-hit rate.
  • Write-triggered invalidation — when the underlying data changes, explicitly delete or update the corresponding cache entry as part of that same write path (this is exactly what write-through caching does automatically). Correct as long as every write path remembers to do it — a write that goes through a different code path, a direct DB migration, a different service entirely, and the cache silently drifts out of sync with nothing to detect it.
  • Event-driven invalidation — the data store emits a change event (often through the message queue patterns in a later chapter) that any interested cache subscribes to and invalidates itself in response. More robust than hoping every write path remembers to invalidate by hand, at the cost of the added infrastructure to publish and consume those events reliably.

Common mistake: Reaching for a longer TTL to "fix" a stale-cache bug instead of fixing the actual invalidation path. A longer TTL doesn't make data more correct — it just makes the staleness window last longer between the same underlying gaps in invalidation logic.