Caching fundamentals
A cache is one bet placed at seven distances — and the interesting number is never the hit rate, it's the miss rate.
Every cache is the same bet, made at a different distance
A cache exists because of exactly one wager: this answer will be asked for again before it changes. Every caching layer in every architecture is that same bet — the layers differ only in how far from the user the copy sits, who pays when the bet is wrong, and how hard it is to take the copy back. Interviewers ask about caching constantly, not because inserting a Redis is hard, but because it is the fastest way to find out whether you reason about a system in rates and distributions or in adjectives. A candidate who says "we'll add a cache" and a candidate who says "at a 90% hit rate this still sends 50k reads a second to the primary, so 90% isn't the goal" are two very different hires.
What each layer actually buys you
| Layer | Caches | Typical TTL | Who can invalidate it | What it really buys |
|---|---|---|---|---|
| Browser / HTTP cache | Static assets, GET responses, service-worker data | Minutes to a year (fingerprinted assets) | Nobody. Once served, it is gone until it expires | Removes the request entirely — the only layer that saves network, not just work |
| CDN edge | Assets, whole pages, API GETs, images | Seconds to days | You, via purge — but propagation takes 1-30 s | Cuts RTT from ~120 ms to ~20 ms and absorbs 80-99% of read traffic before it reaches your region |
| Reverse proxy (nginx, Varnish, Envoy) | Rendered fragments, upstream responses | Seconds to minutes | You, instantly — it's your box | Shields origin from duplicate work; the natural home for request coalescing |
| In-process (Caffeine, an LRU Map) | Config, feature flags, hot rows, compiled templates | Seconds to minutes | Only that process; every instance has its own copy | ~100 ns lookups, zero network — but N instances means N copies and N stale windows |
| Shared cache tier (Redis, Memcached) | Objects, session state, computed aggregates, rate-limit counters | Seconds to hours | You, atomically, for the whole fleet | One consistent copy across all app servers; the workhorse layer |
| Database buffer pool | Pages the storage engine recently touched | Until evicted | Nobody — and you don't want to | Free, already on; the reason "the database is slow" is usually false for hot data |
The buffer pool is the layer candidates forget exists, and it changes the conversation. A Postgres box with 64 GB of RAM and a 40 GB hot set is already serving nearly every read from memory. Adding Redis in front of it does not save you a disk seek — it saves you connection setup, query parsing, planning, MVCC visibility checks and result serialisation. That is still worth 5-20× on latency, but say why, because "the database reads from disk" is frequently wrong and an interviewer who runs databases will notice.
Read strategies: cache-aside vs read-through
Cache-aside (also "lazy loading") puts the application in charge: ask the cache, and on a miss go fetch and populate it yourself. This is the one you will be asked to write on a whiteboard, so write it well — the three-line version everyone produces has three real bugs in it.
async function getUser(id) {
const key = `user:v3:${id}`; // v3 = payload schema version; see invalidation
const hit = await redis.get(key);
// !== null, not truthy: a legitimately cached 0, "" or false is a HIT.
// if (hit) is the single most common bug in this function.
if (hit !== null) return JSON.parse(hit);
const row = await db.users.findById(id); // the miss path — the expensive part
if (row === null) {
// Negative caching. Without it, a scraper hitting nonexistent ids
// passes straight through the cache into the database, every time.
await redis.set(key, "null", "EX", 30);
return null;
}
// Jittered TTL: 300 s ± 30. A million keys populated in the same minute
// must not expire in the same second. This one line prevents avalanche.
const ttl = 300 + Math.floor(Math.random() * 61) - 30;
await redis.set(key, JSON.stringify(row), "EX", ttl);
return row;
}
Note what this function does not do: it never writes to the cache on the write path. The write path deletes the key and lets the next read repopulate it. That asymmetry is deliberate and we'll see why in the invalidation section.
Read-through moves that same logic behind the cache client, so the application only ever talks to the cache and the cache knows how to load a miss. Cleaner code, one place to implement coalescing and metrics — but the cache is now on the critical path for correctness, not just speed. With cache-aside, Redis being down means slow. With read-through, Redis being down means down. That distinction is the whole answer when an interviewer asks which you'd pick.
Write strategies, and when each is right
| Strategy | What the write does | Cost | Failure mode | Reach for this when… |
|---|---|---|---|---|
| Cache-aside + invalidate | Write DB, then DEL the key | None on the write path | A read/write interleaving can leave a stale entry until TTL | The default. Pick this unless you can name why not |
| Write-through | Write cache and DB synchronously, both must succeed | Every write pays both latencies | Caches data that may never be read; a cache outage stalls writes | The same key is read within seconds of being written (profile edit, cart update) and write latency budget is loose |
| Write-behind (write-back) | Write cache, ack the client, flush to DB in batches later | Durability — the ack is a lie until the flush lands | Cache node dies with unflushed writes; ordering across keys is hard | High-volume, low-value-per-write, coalescable data: view counts, likes, "last seen", metrics |
| Write-around | Write DB only, never touch the cache | First read after a write is always a miss | Nothing, which is the point | Bulk imports, logs, audit rows — write-once data that would otherwise evict your hot set |
| Refresh-ahead | Proactively recompute an entry before its TTL expires | Wasted work on keys nobody asks for again | Amplifies load if applied to a large keyspace | A small, known set of extremely hot keys — a homepage feed, a leaderboard, a config blob |
Write-behind is the one worth volunteering. A "like" counter taking 50k increments a second is 50k row updates a second with lock contention on a single hot row — a database will simply refuse. Buffer the increments in Redis, flush the delta every second, and 50k writes become one. You have traded "we might lose the last second of counts if a Redis node dies" for a 50,000× reduction in write load, and for a like counter that is obviously the right trade. Say the trade out loud; that is the whole point of the answer.
Eviction: what happens when memory runs out
TTL is expiry — the entry becomes invalid at a known time. Eviction is what the cache does when it is full and someone wants to write anyway. They are different mechanisms and conflating them is a tell.
| Policy | Keeps | Breaks on | Reach for this when… |
|---|---|---|---|
| LRU | Recently accessed keys | Scans — one analytics query touching a million cold rows flushes your entire working set | General purpose, access is recency-correlated (sessions, recent items) |
| LFU | Frequently accessed keys | Aging — yesterday's viral post keeps a slot forever unless counters decay | A stable long-tail popularity distribution; scan resistance matters |
| FIFO / random | Nothing in particular | Nothing badly, surprisingly | You need O(1) with zero bookkeeping; random eviction is within a few points of LRU in practice |
| TTL-only (volatile-ttl) | Entries furthest from expiry | Keys written without a TTL — they become unevictable | Every entry genuinely has a natural lifetime |
| W-TinyLFU (Caffeine) | Whatever a frequency sketch says earns its slot | Very little; near-optimal hit rates | In-process JVM caches where the extra few percent of hit rate is worth a real library |
Two implementation details worth knowing because they get asked. First,
Redis does not implement true LRU: it samples a handful of keys
(maxmemory-samples, default 5) and evicts the least recently
used of the sample. It gets within a percent or two of exact LRU
for a fraction of the bookkeeping, and it is a nice example of the
approximate-is-fine reasoning these systems are built on. Second, Redis LFU
counters are 8-bit probabilistic counters with logarithmic increment and
time-based decay — you cannot store a true frequency for a billion keys, so
you store something that ranks them correctly and costs one byte.
allkeys-lru because it is a cache. Under memory pressure it
will silently evict a session and log a user out, or evict an idempotency
key and let a duplicate payment through. Anything whose loss is a
correctness bug belongs in a separate instance set to
noeviction, where a full memory condition fails writes loudly
instead of corrupting state quietly.
Invalidation is hard, and here is exactly why
"There are two hard things in computer science" is a joke everyone repeats and almost nobody unpacks. The concrete reason is this: there is no transaction that spans your cache and your database. They are two independent systems, so any two operations against them can interleave, and one specific interleaving is permanently damaging.
Once you can draw that, the practical patterns stop being folklore and become answers to a specific question — how long can a stale entry survive?
- TTL. The honest answer. It does not prevent staleness, it bounds it. A 60-second TTL means the worst case above resolves in at most 60 seconds. Most systems are correct because of TTL, not because of clever invalidation, and saying so is a sign of experience rather than a concession.
- Delete, never update. On write,
DELthe key; do not compute the new value andSETit. Two concurrent writers who bothSETcan land in either order and the loser's value sticks. Two concurrent writers who bothDELconverge on "empty", and the next read repopulates from the committed source of truth. - Versioned keys. The strongest pattern. Never mutate an entry — put the version in the key:
user:42:v17, where 17 comes from a row counter, an ETag, or the row'supdated_at. A write bumps the version, so it is writing to a key nobody will ever read again. The race above becomes harmless: A's staleSETlands onv16, which no reader will ever request. Old entries cost memory until LRU reaps them, and that is the entire price. - Explicit purge / surrogate keys. What CDNs give you: tag a response
product:42and purge every edge copy carrying that tag in one call. Essential at the CDN layer, but remember purge is itself a distributed system — it takes seconds, and it can fail. - CDC-driven invalidation. Tail the database's write-ahead log (Debezium and friends) and emit invalidations from there. This is the only approach where invalidation is derived from committed state, in commit order, with no dual write. It costs you a pipeline; buy it when correctness matters more than simplicity.
Cache-Control: max-age=3600 is sitting
in a browser you will never speak to again for the next hour. This is why
HTML gets no-cache or a short max-age while fingerprinted JS
and CSS get immutable, max-age=31536000. Get that backwards and
a bad deploy is unfixable for everyone who loaded the page.
Thundering herd, and the four ways to stop it
A single key serving 50,000 requests a second expires. Recomputing it takes 20 ms. In that window 50,000 × 0.020 = 1,000 requests all miss, all decide independently to recompute, and all hit the database with the same query at the same instant. The database was comfortably serving one query per five minutes for that key; it now gets a thousand at once. Nothing was misconfigured. The cache working correctly produced the outage.
const inflight = new Map(); // key → Promise, per process
function coalesce(key, loader) {
const existing = inflight.get(key);
if (existing) return existing; // 999 callers join the same promise
// finally() matters: on rejection the entry must clear, or one
// transient error is cached as a permanent failure for that key.
const p = loader().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
Be precise about the scope of that fix: it coalesces within one
process. With 40 app servers you have gone from 1,000 queries to 40,
which is usually enough. If it isn't, the cross-process version is a
SET key NX EX 10 lock in Redis — one winner recomputes, the
losers either wait briefly or serve the stale value — and you should say
out loud that you have just introduced a distributed lock, with the lease
expiry and fencing questions that come with it.
| Technique | Mechanism | Cost | Reach for this when… |
|---|---|---|---|
| Request coalescing / singleflight | One in-flight load per key; everyone else awaits it | A few lines; per-process only | Always. This is the baseline, not an optimisation |
| Jittered TTL | ttl = base ± rand(base × 0.1) | One line, no downside | Always — and specifically whenever many keys are populated together (deploy, warm-up, bulk import) |
| Early / probabilistic recompute | Refresh before expiry with probability rising as expiry nears | A little duplicate work; needs the last recompute duration stored | A handful of extremely hot keys where even one miss is a visible latency spike |
| Stale-while-revalidate | Serve the expired value immediately, refresh in the background | Bounded staleness, by design | Read paths that tolerate a few seconds of stale data — which is most read paths |
| Negative caching + bloom filter | Cache "does not exist"; or test membership before querying | A short stale window on newly created ids | Enumerable keyspaces where a scraper or a bug can request ids that were never real |
The probabilistic version is worth naming precisely because it sounds like
an invented answer otherwise. XFetch: store how long the last recompute
took as delta, and on every read refresh early if
now − delta × beta × ln(random()) ≥ expiry, with
beta around 1. Expensive-to-compute entries and entries close
to expiry refresh sooner, probabilistically, so exactly one unlucky reader
usually does the work before anyone has to wait for it.
Hit-rate arithmetic: why 90% is not a good number
This is the section that wins interviews, because almost nobody does the arithmetic. Take a read tier serving 500,000 reads/sec, backed by replicas that each sustain about 8,000 point reads/sec before p99 degrades.
| Hit rate | Miss rate | Reads reaching the database | Replicas required |
|---|---|---|---|
| 90% | 10% | 500,000 × 0.10 = 50,000/s | 50,000 ÷ 8,000 = 7 |
| 95% | 5% | 500,000 × 0.05 = 25,000/s | 4 |
| 99% | 1% | 500,000 × 0.01 = 5,000/s | 1 |
| 99.9% | 0.1% | 500,000 × 0.001 = 500/s | 1, at 6% utilisation |
Going from 90% to 99% is described in conversation as "nine percentage points". In load terms it is a 10× reduction and it deletes six database replicas. Going the other way is worse: a hit rate slipping from 99% to 98% doubles database load overnight, and no dashboard labelled "cache hit rate 98%" looks alarming. Always reason in miss rate. Hit rate is a vanity metric; miss rate is the thing that is actually multiplied by your traffic.
The same arithmetic explains the latency shape. With a 0.5 ms cache and a 20 ms database:
- 99% hit: mean = 0.99 × 0.5 + 0.01 × 20 = 0.695 ms
- 90% hit: mean = 0.90 × 0.5 + 0.10 × 20 = 2.45 ms — 3.5× worse from a number that "sounds fine"
- And the percentile that matters: at a 10% miss rate, every request above p90 is a cache miss by definition. Your p95 and p99 are not "the cache is a bit slower sometimes" — they are raw database latency, including its tail. A 99% hit rate is what moves the database out of p99 entirely.
Sizing follows the same logic in reverse. Hit rate is a function of how much of the working set fits in memory, and real access distributions are Zipf-ish: the top ~20% of keys serve ~80% of requests. That means the first gigabyte buys you an enormous amount and the climb from 90% to 99% can cost 4-8× the memory, because you are now paying to hold the long tail. Knowing where you are on that curve is what makes "should we double the cache?" a calculable question rather than an argument.
Recognizing it in an unseen problem
- Any prompt with a heavy read:write skew (news feed, product catalogue, URL shortener, profile service) is a caching problem before it is a database problem. State the ratio, then state the miss rate you're targeting and the load that leaves — do not just say "add Redis."
- A naive design adds a cache and stops there: no TTL policy, no invalidation story, no eviction policy, no stampede protection, and no answer for what happens when a cache node dies. Each of those is a follow-up question the interviewer already has queued.
- Distinguish caching from replication: a read replica is authoritative and eventually consistent; a cache is non-authoritative and arbitrarily stale. If the prompt needs "must reflect the last write", a replica with read-your-writes routing is the answer, not a cache.
- Distinguish it from a CDN question: if the payload is large, static and geographically distributed, the win is bandwidth and RTT at the edge, not query offload. Different layer, different invalidation story, same bet.
- Hot-key language ("a celebrity posts", "a flash sale", "one video goes viral") is the interviewer explicitly asking for stampede handling and hot-key mitigation — coalescing, a per-instance L1 in front of the shared tier, or key splitting.
- Pitfall: caching writes. If the prompt is write-heavy or the data is read once and never again, a cache adds latency and memory cost for nothing. Say "I would not cache this, and here's why" at least once during a loop — knowing where a cache does not belong reads as strongly as knowing where it does.