Skip to main content
PProfileMatch

Caching: Strategies, Eviction and Pitfalls

System Design & ArchitectureLesson 3 of 66 min read

What you'll learn

  • Compare cache-aside, read-through, write-through, write-back and write-around
  • Explain eviction policies such as TTL and LRU and implement a small LRU cache
  • Describe cache invalidation problems and how to reduce stale data
  • Recognise and mitigate cache stampede, and know where a CDN fits

On this page

  1. Why caching works
  2. Caching strategies
  3. Eviction policies and TTL
  4. Invalidation and consistency
  5. Cache stampede and hot keys
  6. CDN: caching close to the user
  7. Common mistakes
  8. How this shows up in interviews and real projects

Every time a product page loads, the same product description is fetched from the database for the thousandth time. A cache keeps a copy of frequently used data in fast storage, usually memory, so most requests never touch the database at all. It is often the cheapest performance win in a design, and also a rich source of subtle bugs.

Why caching works

Real traffic is skewed: a small share of items (popular products, trending posts) gets most of the reads. Memory is far faster than disk or a network call to a database. Combine the two and a cache can cut average latency and shield the database from load. The average latency is the weighted mix of hits and misses. Using illustrative numbers of 1 ms for a cache hit, 20 ms for a database read and a 90 percent hit ratio, the average is 0.9 x 1 + 0.1 x 20 = 2.9 ms, against 20 ms with no cache. The hit ratio is the number to watch: raising it from 90 to 99 percent drops the average to about 1.2 ms in this example.

Caches can live at several levels: in the browser, on a CDN, inside the application process, in a shared cache such as Redis or Memcached, and in the database itself. Closer to the user is faster, but harder to invalidate.

Caching strategies

StrategyHow it worksProsCons
Cache-aside (lazy loading)App checks cache; on miss it reads the DB and fills the cacheSimple; only caches what is requested; cache failure is survivableFirst request is slow; data can go stale
Read-throughApp always asks the cache, which loads from the DB on a missCleaner app codeCache layer must know how to load data
Write-throughWrites go to the cache and the DB together, synchronouslyCache is always freshSlower writes; caches data that may never be read
Write-back (write-behind)Writes hit the cache first and are flushed to the DB laterVery fast writes; batchingData loss if the cache dies before flushing
Write-aroundWrites go straight to the DB, skipping the cacheAvoids filling the cache with rarely-read dataNext read is a miss

Cache-aside is the most common default. The flow looks like this:

Cache-aside read path
  App server
     |  1. GET profile:42
     v
  +-------+   hit    returns data immediately
  | Cache |--------->
  +-------+
     | miss
     v  2. SELECT ... WHERE id = 42
  +----------+
  | Database |
  +----------+
     |  3. return row
     v
  App server: 4. SET profile:42 (TTL 5 min)
              5. respond to client
cacheAside.ts
async function getProfile(id: string) {
  const key = "profile:" + id;

  const cached = await cache.get(key);
  if (cached !== null) {
    return JSON.parse(cached); // cache hit
  }

  const row = await db.findProfile(id); // cache miss: go to the source
  if (row) {
    // TTL bounds how stale this entry can get
    await cache.set(key, JSON.stringify(row), { ttlSeconds: 300 });
  }
  return row;
}

async function updateProfile(id: string, changes: object) {
  await db.updateProfile(id, changes);
  await cache.del("profile:" + id); // invalidate so the next read reloads
}

Eviction policies and TTL

Memory is limited, so a full cache must decide what to throw out. A TTL (time to live) expires an entry after a fixed time and bounds staleness. An eviction policy chooses victims when memory is full.

PolicyEvictsGood for
LRU (least recently used)The item not touched for the longest timeGeneral-purpose default; recency predicts reuse
LFU (least frequently used)The item used the fewest timesStable popularity, such as a hot catalogue
FIFOThe oldest inserted itemSimple; ignores usage so rarely best
TTL onlyItems past their expiryData with a natural freshness window

LRU is a classic interview exercise. A Map in JavaScript keeps insertion order, so re-inserting a key on each access moves it to the "most recent" end, and the first key is the least recently used.

LRUCache.ts
class LRUCache<K, V> {
  private map = new Map<K, V>();

  constructor(private capacity: number) {}

  get(key: K): V | undefined {
    if (!this.map.has(key)) return undefined;
    const value = this.map.get(key) as V;
    this.map.delete(key);
    this.map.set(key, value); // move to the most recent position
    return value;
  }

  set(key: K, value: V): void {
    if (this.map.has(key)) {
      this.map.delete(key);
    } else if (this.map.size >= this.capacity) {
      const oldest = this.map.keys().next().value as K;
      this.map.delete(oldest); // evict least recently used
    }
    this.map.set(key, value);
  }
}

const cache = new LRUCache<string, number>(2);
cache.set("a", 1);
cache.set("b", 2);
cache.get("a");     // touches "a", so "b" is now the oldest
cache.set("c", 3);  // evicts "b"
console.log(cache.get("b"), cache.get("a"));

Output

undefined 1

Invalidation and consistency

"There are only two hard things in computer science: cache invalidation and naming things" is a joke with a point. The cache and the database can disagree, and you need a policy for how long that is acceptable. The usual tools are, in order of simplicity: rely on a short TTL; delete the cache entry when the data is written (as in updateProfile above); include a version in the key (profile:42:v7) so changes naturally miss; or publish invalidation events from the database layer.

Be careful with race conditions. If one request reads old data from the database while another updates it and clears the cache, the first request can write the stale value back into the cache after the delete. A TTL is your safety net for such cases, which is a good reason to set one even when you also invalidate explicitly.

Cache stampede and hot keys

A cache stampede (or thundering herd) happens when a popular entry expires and thousands of requests miss at the same moment, all hitting the database to rebuild it. The database can be overwhelmed, which makes the rebuild slower, which makes more requests pile up.

  • Request coalescing or locking: let one request rebuild the value while others wait or serve the old value.
  • TTL jitter: add a small random amount to each TTL so many keys do not expire together.
  • Early refresh: refresh popular entries in the background shortly before they expire.
  • Serve stale while revalidating: briefly return the expired value while one worker fetches the new one.

CDN: caching close to the user

A content delivery network is a cache spread across many locations. Static assets such as images, scripts, stylesheets and videos are cached at edge servers near users, which reduces latency and removes load from your origin servers. Use long cache lifetimes with file names that include a content hash (app.3f9c2.js), so a new deploy changes the URL instead of needing invalidation. Dynamic, personalised responses are usually not cacheable at the CDN, or only with care.

Common mistakes

  • Caching without a TTL. Symptom: users see old data until someone manually clears the cache, and bugs surface weeks later.
  • Forgetting invalidation on writes. Symptom: a user updates their profile and still sees the old one.
  • Cache as the only copy. Treating a cache as durable storage. Symptom: data vanishes after a restart or eviction.
  • Caching everything. Low-hit-ratio entries waste memory and push out useful ones. Measure hit ratio before and after.
  • Ignoring stampedes on popular keys. Symptom: periodic latency spikes exactly when a popular entry expires.

How this shows up in interviews and real projects

Interviewers expect you to say where the cache sits, which strategy you use, what the eviction and expiry policy is, and what happens on a miss or a cache failure. In real projects, the best first step is to measure hit ratio and latency, then cache the few queries that dominate. In the URL shortener worked example, caching is what makes a read-heavy system cheap to run.

Try it yourself

Design the caching for a product page on an e-commerce site: say which data you would cache (description, price, stock), which strategy and TTL you would use for each, and what you would do when the price changes. Then calculate the average latency if the cache hit ratio is 95 percent, hits take 2 ms and misses take 30 ms (illustrative values).

Show a hint

Average = 0.95 x 2 + 0.05 x 30 = 3.4 ms. Stock during checkout probably should not come from a cache, or should be re-checked against the database at order time.

Key takeaways

  • Caching trades freshness and complexity for speed; the hit ratio decides how much you gain.
  • Cache-aside is the usual default; write-through favours freshness, write-back favours write speed but risks data loss.
  • Always set a TTL, pick an eviction policy such as LRU, and have a plan for invalidating on writes.
  • Guard popular keys against stampedes with coalescing, jitter or early refresh.
  • Use a CDN for static content and hash file names to avoid manual invalidation.

Test yourself

A trick from this lesson's ride: Cache-aside: the app does the legwork. Read-through: the cache does. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between write-through and write-back caching?

Write-through writes to the cache and the database together, so the cache is always up to date but writes are slower. Write-back writes to the cache first and flushes to the database later, which is faster but can lose data if the cache fails before the flush.

What is cache invalidation and why is it hard?

Cache invalidation is removing or updating cached entries when the underlying data changes. It is hard because the cache and the source of truth are separate systems, so timing and race conditions can leave stale data. Short TTLs plus explicit deletion on writes are the common safeguards.

What is a cache stampede and how do I prevent it?

A cache stampede occurs when a popular cache entry expires and many requests simultaneously miss and hit the database to rebuild it. Prevent it with request coalescing or locks, randomised TTLs, refreshing hot keys before they expire, or serving slightly stale data while one request refreshes it.