07 · Performance

Caching

6 min read · 5 questions · 1 exercise

A cache keeps the answer to work you have already done so the next request can skip doing it again.

Every visitor to your product page triggers the same query: fetch the category tree, join it to the product counts, sort the result. The database walks the same rows and returns the same answer it returned to the previous visitor a few milliseconds ago, and to the thousand visitors before that. Nothing about the answer has changed since yesterday. You are paying for the same computation over and over because nobody kept the result.

The fastest work is the work you do not repeat. A cache is a place to keep an answer you already produced, close to whoever needs it next — a note stuck to your monitor with the number you keep looking up, instead of walking to the filing cabinet each time. The note is instant. It is also, from the moment the number changes, quietly wrong, and that tension is the whole subject.

Cache-aside

The most common pattern puts the application in charge. Before doing the expensive work, look in the cache. If the value is there and still valid — a hit — return it and stop. If it is not — a miss — do the real work, store the result in the cache, and return it. The next request for that key is a hit.

It is called cache-aside because the cache sits beside the source of truth rather than in front of it, and the application decides what goes in. Two consequences follow. Only requested data is ever cached, so the cache fills itself with whatever is actually popular. And if the cache disappears completely, every request becomes a miss and the system still returns correct answers, just slowly — a failure mode you can survive at 3am.

In the scene below, the badge on each box counts arrivals. Watch the two badges diverge: the cache keeps taking requests while the database stops being asked.

Predict first

The scene starts with an empty cache and sends 8 reads for 3 users: user:42 four times, user:7 twice, user:19 twice. How many of those reads reach the database?

  1. That is what happens with no cache at all. Here the first read of each key stores its answer, so every repeat after it is served from memory and never leaves the cache.

  2. Five is the number the cache answers on its own, which is the hits. It is easy to swap the two counts, so it helps to name them: misses are what the database sees, hits are what it is spared.

  3. One miss per distinct key. The first read of user:42, user:7 and user:19 each pays for a database trip; the other five are repeats and hit. That is a hit rate of 5 in 8.

  4. A cache stores one answer per key, not one answer for the whole query. The stored user:42 cannot answer a read for user:7, so each new key misses once.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Cache-aside: misses, then hits

one read · teal = hit, amber = miss · cache badge all reads, db badge misses

client
cache
database

Every read tries the cache first — use Next step to go one at a time.

Hit rate, and what moves it

A cache earns its keep through one number: the fraction of requests it answers without the underlying work. At a high hit rate the database sees a trickle of traffic instead of a flood. At a low hit rate the system has taken on a network hop, a serialization step, a pile of memory, and a second copy of the data to reason about, in exchange for nothing. A cache that misses is not neutral; it is overhead.

Put numbers on it, because the intuition here is usually wrong. At 1,000 reads a second, a 90% hit rate sends 100 reads to the database, and a 99% hit rate sends 10. Those two hit rates look nine points apart; to the database they are a tenfold difference in load. What the database feels is the miss rate, so the last few points of hit rate are worth far more than the first fifty. The lap in the scene above, with three keys and eight reads, hits 5 in 8. Real traffic, with the same few keys asked for thousands of times, does much better.

What moves that number is how concentrated the traffic is. Real access patterns are lopsided — a small set of keys accounts for most requests — and caches exploit exactly that skew. When the cache fills, an eviction policy decides what to drop, usually least-recently-used: the entry nobody has touched for the longest is the cheapest guess to throw away.

The other lever is time to live, or TTL: every entry gets an expiry, after which it is treated as absent and the next request repopulates it. TTL is crude — it expires entries that were still perfectly correct — but it is the single most valuable safety valve in caching, because it puts a hard ceiling on how wrong you can be. A stale price with a 30-second TTL is a 30-second problem. Set it per key, not globally: a currency rate and a country list do not deserve the same number.

Invalidation, the part that is actually hard

Storing a value is easy. Knowing when the stored value stopped being true is not, and this is where caching stops being free.

There are two ways to get it wrong. Serve stale data: a write lands in the database, the cache still holds the previous copy, and users see an old answer with total confidence. Or invalidate too eagerly: clear so aggressively that the cache is never warm, and the complexity stays while the benefit goes.

TTL bounds staleness by the clock. Explicit invalidation — deleting or rewriting cache entries when a write happens — bounds it by event, which is tighter but demands something harder: knowing every key a given write invalidates. Rename one user and you may need to touch their profile payload, the comment list that embeds their name, the team roster, and the search document. Miss one and you have a stale corner that nobody notices for weeks. This is why experienced teams cache aggressively at the edges and cautiously in the middle, and why TTLs stay short on anything that changes.

One more failure mode deserves naming. When a popular key expires, every request that arrives in the next instant misses simultaneously, and all of them go to the database at once — a stampede, and it lands precisely when you are busiest. The standard defenses are to let a single request perform the refresh while the others wait or receive the old value, and to add random jitter to TTLs so that keys written together do not all expire together.

Cache-aside with a TTL is short enough to read in one sitting:

interface User {
  id: string;
  name: string;
}

interface CacheEntry {
  value: User;
  expiresAt: number;
}

const TTL_MS = 60_000;
const cache = new Map<string, CacheEntry>();

async function getUser(
  id: string,
  loadUser: (id: string) => Promise<User>,
): Promise<User> {
  const cached = cache.get(id);
  if (cached && cached.expiresAt > Date.now()) return cached.value;

  const user = await loadUser(id);
  cache.set(id, { value: user, expiresAt: Date.now() + TTL_MS });
  return user;
}

The stampede is visible in that code: nothing prevents two concurrent misses for the same id from both calling loadUser. A single in-flight map — key to pending promise — collapses them into one database read, and is usually the first thing added to a cache like this.

Try it

Collapse the stampede

Change getUser so that concurrent misses for the same id share one loadUser call. Two calls made back to back on an empty cache, before either has finished, should call loadUser exactly once and both resolve to the same user.

Hint

Keep the pending promise, not the value: a Map<string, Promise<User>> keyed by id. Check it after the cache and before calling loadUser. Then ask what happens to that map entry if the load fails.

Solution
const inFlight = new Map<string, Promise<User>>();

async function getUser(
  id: string,
  loadUser: (id: string) => Promise<User>,
): Promise<User> {
  const cached = cache.get(id);
  if (cached && cached.expiresAt > Date.now()) return cached.value;

  const pending = inFlight.get(id);
  if (pending) return pending;

  const load = loadUser(id)
    .then((user) => {
      cache.set(id, { value: user, expiresAt: Date.now() + TTL_MS });
      return user;
    })
    .finally(() => inFlight.delete(id));
  inFlight.set(id, load);
  return load;
}

The map entry exists from the moment the first miss starts until its load settles, so every request that arrives in that window awaits the same promise. The finally matters as much as the map: without it, one failed load would sit in inFlight as a rejected promise, and every later request for that user would fail instantly without ever retrying.

To see it work, count the calls:

let calls = 0;
const load = async (id: string): Promise<User> => {
  calls += 1;
  return { id, name: 'Ada' };
};

await Promise.all([getUser('42', load), getUser('42', load)]);
console.log(calls); // 1 (it was 2 before the change)

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

An admin changes a product's price. Reads go through cache-aside with a 10-minute TTL and no explicit invalidation. What do customers see?

  1. On a hit, cache-aside never asks the database. The database being right does not help a reader whose request stops at the cache.

  2. The cached copy is served until its TTL runs out, and the next read after that misses and fetches the new price. This is what the TTL buys you: a guaranteed ceiling on how long an answer can stay wrong.

  3. Nothing compares the two. That is what makes stale data dangerous: it is served with the same confidence as fresh data, and no one is warned.

  4. That would be true of a cache with no TTL. The expiry exists precisely so that a missed invalidation heals itself.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Your database is comfortable at 50 reads a second. Traffic is 1,000 reads a second, all through the cache. What is the lowest hit rate that keeps the database comfortable?

  1. Half of 1,000 still misses, so the database gets 500 reads a second, ten times what it can take.

  2. Sounds high, but 10% of 1,000 is 100 misses a second, twice the limit. This is the trap of thinking in hit rate rather than miss rate.

  3. A 5% miss rate on 1,000 reads is exactly 50 reads a second. Work from the miss rate: allowed misses ÷ total reads = 50 ÷ 1,000 = 5%, so the hit rate must be at least 95%.

  4. This works, with only 10 misses a second, but it is not the lowest. Going from 95% to 99% cuts database load fivefold, which shows how much those last few points are worth.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

A homepage key is read 2,000 times a second and has a 60-second TTL. Once a minute, the database load spikes hard. What is happening?

  1. A longer TTL only makes the spike rarer; a shorter one makes it more frequent. The spike comes from what happens at the moment of expiry, not from how long the entry lives.

  2. A key read 2,000 times a second is the most recently used entry in the cache, so LRU would evict it last. The timing, once a minute, points at the TTL.

  3. Averaged over the minute, the hit rate is above 99.8%. The average hides the problem, because all the misses arrive in the same instant.

  4. This is a stampede. At 2,000 reads a second, a 100 ms database query lets about 200 requests miss together, and every one of them goes to the database. The fix is a single in-flight load per key that the others wait on, which is the exercise above.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

A user renames themselves. Your code deletes user:42 from the cache straight away. A day later, the comment thread still shows their old name. Why?

  1. Invalidation has to cover every cached value built from the data, not only the one stored under its obvious key. The thread was cached with the name copied into it, and nothing deleted that entry. This is the hard part of invalidation the section describes.

  2. It did: the profile page shows the new name. The stale name is coming from a different cache entry.

  3. If it had not, the profile would be stale too. The rename committed, and one stale corner was left behind.

  4. That can happen, but it would not survive a day of other visitors loading fresh pages. The staleness is on the server, in a derived cache entry.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

Key takeaways

  • Cache-aside means the application checks the cache first, does the real work only on a miss, and stores the result on the way back.

  • A cache is only worth its complexity at a high hit rate; below that it is pure overhead added to every request.

  • TTL puts a ceiling on how stale an answer can be, which is why it belongs on every cached entry even when explicit invalidation exists.

  • The hard part of caching is never storage — it is knowing every key a write has just made wrong.