Concept 2: Caching Strategies
System Design Bible — File 02 of 10 Difficulty: ★★☆☆☆ Prerequisites: File 01, plus the memory-hierarchy and statelessness ideas unpacked in Section 0.
Table of Contents#
- Prerequisites — What You Must Understand First
- Architectural Definition & "The Why"
- The Recursive Sub-Concept Tree
- Deep-Dive Mechanics: The Caching Patterns
- Eviction Policies — Exhaustive Breakdown
- Cache Invalidation & Consistency
- The Three Cache Killers: Penetration, Avalanche, Stampede
- HTTP Caching & Client-Side Semantics
- Pros, Cons & Trade-offs
- Interview Diagnostic Framework (Symptom → Solution)
- Real Implementations & Product Landscape
- Real-World Engineering Scenarios
- Interview Gotchas & Failure Modes
- Whiteboard Cheat Sheet
0Prerequisites — What You Must Understand First#
Caching is one idea — "keep a copy of expensive-to-get data somewhere fast" — but it only makes sense once five background facts are concrete. The first three are about why a fast copy is worth having; the last two are about the machinery that makes a cache work at all, and they are the ones that later sections (key design, serialization cost, the read-path race in §3.6) silently assume.
0.1The memory hierarchy (why "fast" and "slow" storage both exist)#
Computers don't have one kind of storage; they have a hierarchy, and every level trades speed against size and cost. At the top: CPU registers and caches (L1/L2/L3) — unbelievably fast (nanoseconds) but tiny (kilobytes to megabytes). Below that: main memory (RAM) — very fast (~100 ns) and moderately sized (gigabytes), but volatile (wiped on power loss) and relatively expensive per gigabyte. Below that: disk (SSD/HDD) — slow (microseconds to milliseconds) but huge (terabytes) and cheap and durable (survives power loss). And across a network: another machine's RAM or disk — add network latency on top. The universal rule: the faster a storage tier, the smaller and more expensive it is. You cannot simply "put everything in RAM" — you don't have enough RAM, and RAM loses data on restart, so the source of truth must live on durable disk (a database). Caching is the art of keeping a small, hot subset of that disk-based truth in a fast tier (RAM) so most requests never pay the slow-tier price. Everything in this file follows from this hierarchy.
0.2Why the database is the bottleneck (and can't just be scaled like app servers)#
In File 01 you scaled the application tier trivially: app servers are stateless (they hold no permanent data), so you can run 100 identical copies behind a load balancer and any one can serve any request. The database cannot be scaled this way, and understanding why is the whole motivation for caching. The database holds state — the actual, authoritative data — and that state must stay consistent. You can't just run 100 independent copies all accepting writes, because they'd immediately disagree about what the data is (which is the hard problem of File 08). So the database is a shared, hard-to-replicate chokepoint: every app server funnels its reads and writes into it, and it can only do so much before its disk, locks, CPU, and connection pool saturate. Since in most systems reads vastly outnumber writes (often 100:1 or 1000:1 — think how many times a popular tweet is read vs written), the highest-leverage move is to stop sending all those repeated reads to the database. That's caching: absorb the flood of reads in a fast tier so the precious database only handles writes and the occasional cache miss.
0.3Latency as a felt, monetized thing#
"Fast" isn't an abstract virtue — latency is felt by users and costs money. Studies at Amazon and Google found that every extra 100 ms of latency measurably reduced sales and engagement; users abandon slow pages. A database query that hits disk and crosses the network might take 20–100 ms, while the same answer from an in-memory cache takes 0.1–1 ms — a 100× improvement that turns a sluggish page into an instant one. So caching isn't just an efficiency tweak for the backend; it's directly a product/revenue lever. Keep this in mind: when you add a cache in an interview, you're buying both database relief and user-facing speed, and both are worth stating.
0.4The key–value model, serialization, and what "storing an object in Redis" actually costs#
Every cache in this file is fundamentally a key–value store: a giant dictionary mapping an opaque key (a string, usually) to an opaque value (a blob of bytes). It is the simplest possible database — no schema, no joins, no query language, no secondary indexes, no WHERE clause. You can only do three things: GET key, SET key value, DELETE key.
Why that restriction is a feature, not a limitation. Because there is exactly one access path — hash the key, jump to the slot — a lookup is O(1) and involves no query planning, no index traversal, no lock on a table, and no disk seek. A relational database's SELECT * FROM users WHERE id = 42 must parse SQL, plan the query, traverse a B-tree index, possibly fetch pages from disk, and honour transaction isolation. The cache's GET user:42 hashes 7 bytes and returns a pointer. That gap — a few hundred nanoseconds versus a few milliseconds — is the cache's value proposition, and it exists precisely because the cache refuses to do anything clever. The corollary you must internalize: a cache cannot answer a question you have not already turned into a key. There is no "give me all users in California" unless you explicitly computed that list and stored it under a key like users:state:CA. Designing what the keys are is designing the cache.
Serialization — the hidden cost. Your application holds a User object: a structure in memory with fields, pointers, and type information. The cache holds bytes. Converting between the two is serialization (object → bytes, for writing) and deserialization (bytes → object, for reading), and it is not free — it is often the single largest CPU cost in a cache hit, dwarfing the network time. The format choices:
- JSON — human-readable text. Universally supported, debuggable (you can
GETa key and read it), and slow: parsing text into objects is expensive, and the output is verbose (field names repeated in every single value). AUserthat occupies 200 bytes of packed binary might be 600 bytes of JSON, and you are paying for that inflation in RAM, in network bytes, and in parse time, on every single request. - Protocol Buffers / Avro / Thrift — binary formats with a schema defined separately. Compact (field names are replaced by integer tags), fast to parse, and schema-evolvable (you can add a field without breaking old readers). Cost: you must manage schemas, and you cannot eyeball a value in
redis-cliany more. - MessagePack / CBOR — schemaless binary, a middle ground: roughly JSON's model at roughly half the size and several times the speed.
- Language-native serialization (Java
Serializable, Pythonpickle) — convenient and a trap. It is slow, it bloats, it locks your cache to one language, and deserializing untrusted bytes is a remote-code-execution vulnerability in both of those examples. Never use it for a shared cache.
Compression sits on top: LZ4 or Snappy for speed (compress at gigabytes per second, typical 2× reduction), zstd or gzip for ratio (3–4× reduction, meaningfully more CPU). The trade is always the same shape — CPU time to buy RAM and network bytes — and it is worth it exactly when your values are large and text-like, and not worth it when values are small (compression headers can make a 60-byte value bigger).
Hold this: a cache stores bytes under keys, so every hit pays serialization, and the format you choose is a real latency and memory decision, not a detail.
0.5Concurrency, atomicity, and why caches have races#
Everything in §3 and §6 is a concurrency story, so this must be concrete. Your service does not handle one request at a time; it handles thousands simultaneously, on many threads across many machines. Those requests interleave in unpredictable order, and any sequence of operations that is not atomic can be interrupted between its steps by another request doing the same thing.
Atomic means indivisible: an operation that either fully happens or does not, with no observable intermediate state, and which cannot interleave with another atomic operation on the same data. SET key value in Redis is atomic. But the pattern "read the DB, then write the cache" is two operations with a gap between them, and in that gap anything can happen — which is exactly the bug in §3.6.
Walk the classic lost update to make the shape unmistakable. Two requests both increment a view counter cached as a plain value:
Request A Request B cached value
GET counter -> 10 10
GET counter -> 10 10
compute 10+1 = 11 10
compute 10+1 = 11 10
SET counter = 11 11
SET counter = 11 11 ← should be 12Two views recorded, one counted. Nothing crashed, no error was logged, the number is simply wrong — and it will be wrong more often the more traffic you have, which is the worst possible property. The fix is to make the whole read-modify-write one atomic operation: Redis's INCR does the increment inside the server, where commands are executed one at a time, so no interleaving is possible. This is why "use the data structure's atomic operation instead of read-modify-write" is a rule rather than a preference.
The three tools you will see used against races in this file:
- Atomic single commands —
INCR,SETNX("set if not exists", which is how you claim a lock),GETSET,EXPIRE. One round trip, no gap. - Server-side scripts / transactions — a Lua script in Redis runs to completion without any other command interleaving, so you can do a multi-step read-modify-write atomically.
MULTI/EXECqueues commands and executes them as one unit. Cost: while your script runs, the whole server is blocked — a slow script is a full outage, since Redis executes commands on a single thread (§10.1). - Distributed locks — one requester wins the right to do the expensive work, everyone else waits. This is the mutex fix for stampede in §6.3. The trap: a lock in a distributed system needs a timeout (or a crashed holder blocks everyone forever), and a timeout means the lock can expire while the holder still believes it holds it — which is why correct distributed locking is genuinely hard and gets its own treatment in the concurrency material referenced by File 08.
Hold this: any cache operation that is not a single atomic command has a gap in it, and at scale every gap is eventually exploited by an unlucky interleaving.
1Architectural Definition & "The Why"#
1.1The Plain Definition#
A cache is a high-speed data storage layer that stores a subset of data — typically the results of expensive computations or frequently-accessed records — so that future requests for that data are served faster than recomputing or re-fetching from the original slower source (the "source of truth" / "origin" / "backing store", usually a database or a downstream service).
Caching is fundamentally a space-for-time trade: you spend memory to buy latency reduction and load reduction.
1.2"The Why" — The Latency Pyramid#
Caching exists because of the brutal, non-negotiable physics of the memory hierarchy. Different storage media differ in access speed by orders of magnitude. Every senior engineer should have these "latency numbers every programmer should know" burned in:
L1 CPU cache reference ............... ~1 ns
L2 CPU cache reference ............... ~4 ns
Main memory (RAM) reference .......... ~100 ns
Read 1 MB sequentially from RAM ...... ~10 microseconds
SSD random read ...................... ~16 microseconds
Round trip within same datacenter .... ~500 microseconds (0.5 ms)
Read 1 MB from SSD ................... ~1 ms
Disk (HDD) seek ...................... ~10 ms
Read 1 MB from disk .................. ~20 ms
Network round trip CA -> Netherlands . ~150 msThe punchline: RAM is ~1000× faster than disk, and a same-datacenter network hop is ~300× faster than a cross-continent one. A database query that hits disk and crosses the network might take 20–100 ms; the same answer served from an in-memory cache takes 0.1–1 ms. That's a 100× improvement.
1.3The Problems Caching Solves#
Historically, applications hit two walls:
- The database read bottleneck. As traffic grows, the database (the shared source of truth) becomes the bottleneck — it can only do so many reads/sec before disk I/O, lock contention, and connection limits choke it. You cannot trivially "add more databases" for reads the way you add stateless app servers, because databases hold state and must stay consistent. Reads vastly outnumber writes in most systems (often 100:1 or 1000:1). Caching absorbs the read load so the database only sees the small write traffic and cache misses.
- Repeated expensive computation. Rendering a personalized feed, running an ML inference, aggregating a report — if the answer is the same for many requests or changes slowly, recomputing it every time is waste. Cache the result.
Connecting to File 01: We ended load balancing by saying "make your servers stateless — externalize session state to a shared store." That shared store is very often a distributed cache (Redis/Memcached). Caching is what enables the stateless-server design that makes horizontal scaling clean.
2The Recursive Sub-Concept Tree#
2.1Sub-Concept: Cache Hit, Cache Miss, Hit Ratio#
- Cache hit: the requested data is found in the cache. Fast path.
- Cache miss: the data is not in the cache; you must go to the origin (database), which is slow, and typically then populate the cache.
- Hit ratio (hit rate):
hits / (hits + misses). The single most important cache health metric. A cache with a 95% hit ratio serves 95% of requests from fast memory and only bothers the DB for 5%. Below ~80% and you should question whether the cache is helping enough to justify its complexity and consistency risk.
Why the hit ratio matters more than it looks — the arithmetic that surprises people. Hit ratio does not improve latency linearly; it improves it hyperbolically, because the misses dominate the average. Suppose a hit costs 1 ms and a miss costs 50 ms. Average latency is hit_ratio × 1 + (1 − hit_ratio) × 50:
| Hit ratio | Average latency | DB load (relative to no cache) |
|---|---|---|
| 0% | 50.0 ms | 100% |
| 50% | 25.5 ms | 50% |
| 90% | 5.9 ms | 10% |
| 95% | 3.5 ms | 5% |
| 99% | 1.5 ms | 1% |
| 99.9% | 1.05 ms | 0.1% |
Read that table twice, because two lessons live in it. First, going from 90% to 99% halves your database load again — the last few percent are where the remaining load lives, so "we're already at 90%, why bother" is wrong. Second, the average is a liar: at 95% hit ratio your median user sees 1 ms and your p99 user sees the full 50 ms miss, so the cache improves the typical experience far more than it improves the worst one. When an interviewer asks about tail latency, this is the point to make.
Two more metrics that matter and are usually forgotten. The miss penalty — how expensive is a miss, really? A cache in front of a 5 ms query is worth much less than one in front of a 2-second report. And the cost of a hit that shouldn't have been — a stale hit is not a success, it is a correctness failure that your hit-ratio dashboard will happily report as a win. Hit ratio measures performance, never correctness.
How to actually read it. In Redis, INFO stats reports keyspace_hits and keyspace_misses; the ratio is computed by you, and note it is server-wide, not per key-prefix — so one badly-behaved key pattern can be hidden by a well-behaved one. Instrument hit ratio per cache-use-case in your application, not only at the server.
2.2Sub-Concept: TTL (Time To Live)#
TTL is an expiration timer attached to a cached entry. After TTL seconds, the entry is considered stale and is either evicted or refreshed on next access. TTL is your primary defense against serving forever-stale data: even if you never explicitly invalidate, the entry self-destructs after the TTL. Short TTL = fresher data but lower hit ratio + more origin load; long TTL = higher hit ratio but staler data. This tension is central to caching.
How expiration is actually implemented — and why it is not a timer. The naive mental model is that each key has a countdown and something fires when it hits zero. Nothing does that, because a server holding fifty million keys cannot afford fifty million timers. Real caches use two mechanisms together:
- Lazy (passive) expiration: the TTL is stored as an absolute expiry timestamp alongside the value. Nothing happens when that moment passes. The next time someone reads the key, the server compares the stored timestamp against the clock, finds it in the past, deletes the key, and reports a miss. Cost: a key that expires and is never read again occupies memory forever. This is a real leak: expired-but-untouched keys can hold gigabytes.
- Active (sampling) expiration: to reclaim that memory, the server periodically wakes up (Redis does this ten times a second by default) and takes a random sample of keys that have TTLs set — twenty of them — deletes the expired ones, and if more than a quarter of the sample turned out to be expired, immediately repeats, on the reasoning that a high expired fraction means there are many more to find. It is a probabilistic sweep, not a scan, because a full scan of the keyspace would block the server. Consequence: expired keys are removed approximately promptly, not exactly, and Redis's own documentation states the design targets under 25% of expired keys lingering at any moment.
The consequence you can be asked about: memory usage does not drop the instant a TTL passes, and a key can be logically expired but physically present. If you are debugging "why is memory still full," this is the mechanism to name.
Choosing a TTL — the actual reasoning, not a guess. The right TTL is derived from how stale the business can tolerate this data being, bounded by how much origin load you can afford. Work it in that order:
- Ask what breaks if this value is
Xseconds old. A user's display name: minutes are fine. A product's price: seconds, and even that may be unacceptable in a regulated market. A stock ticker: no cache, or sub-second. An account balance shown before a payment: do not cache at all — go to the source (File 15). - Compute the origin load that TTL implies: origin QPS ≈ (number of distinct keys) / TTL, in steady state, because each key must be refetched once per TTL. A million keys with a 60-second TTL is ~16,700 origin reads/sec just from expiry — a number that will surprise you the first time you compute it, and which alone can decide the TTL.
- Always add jitter (§6.2). A fixed TTL applied to keys loaded together makes them expire together.
TTL is not the only expiry knob. Absolute TTL (expire at a fixed moment, EXPIREAT) is right for data that becomes invalid at a known time — a daily report, a token with a known expiry. Sliding TTL (the timer resets on every access, which you implement by re-issuing EXPIRE on read) keeps hot data alive indefinitely and is right for sessions, where "expire 30 minutes after last activity" is exactly the semantic you want. Confusing the two produces sessions that log users out mid-work, or reports that never refresh.
2.3Sub-Concept: Eviction#
Caches are bounded (limited RAM). When the cache is full and you need to insert a new entry, you must evict (remove) an existing one. The eviction policy decides which entry to sacrifice (LRU, LFU, etc. — full treatment in Section 4). Note: eviction (capacity-driven removal) is different from expiration (TTL-driven removal) and different from invalidation (correctness-driven removal because the underlying data changed). Interviewers test whether you conflate these three.
Say the distinction precisely, because it is a genuine tell:
| Removal type | Trigger | Whose decision | What it means about correctness |
|---|---|---|---|
| Eviction | Cache is out of memory | The cache's policy (§4) | Nothing — the data was still valid, you just had no room |
| Expiration | A TTL elapsed (§2.2) | Your TTL choice | You declared this data untrustworthy after N seconds |
| Invalidation | The underlying data changed | Your application, or a change event | The cached copy became wrong, and serving it is a bug |
The consequences differ sharply. Eviction reduces your hit ratio but never serves wrong data. Expiration is a blunt correctness instrument — it bounds staleness but does not eliminate it, since a value can be wrong for the entire TTL window. Only invalidation is targeted correctness, and it is the hardest of the three because it demands you know every key affected by a write (§5).
A fourth removal you should know: memory-pressure eviction of the wrong thing. If your cache holds both session data (loss = users logged out) and cached query results (loss = a slow page), an ordinary LRU eviction under memory pressure will happily throw away sessions. The fix is separate caches or separate logical databases per workload — never mix data with different loss consequences in one eviction pool. Facebook's "pools" (§11.1) exist for exactly this reason.
2.4Sub-Concept: Cache Placement — Where Caches Live#
Caches exist at every layer of a system. Know the taxonomy:
- Client-side cache: browser cache, mobile app cache. Closest to the user, zero network cost, but you can't invalidate it easily (it's on someone else's device).
- CDN / edge cache: caches static (and increasingly dynamic) content geographically near users. Entire File 05 is about this.
- Load balancer / reverse-proxy cache: e.g., Nginx/Varnish caching responses.
- Application-local (in-process) cache: a hash map inside the app server's own memory (e.g., Guava Cache, Caffeine). Nanosecond access, but not shared — each server has its own copy, causing inconsistency, and it's lost on restart.
- Distributed / remote cache: a dedicated cluster (Redis, Memcached) shared by all app servers. One network hop away (~0.5 ms), but shared and consistent across the fleet, survives app restarts. The workhorse of system design.
- Database-internal cache: the DB's own buffer pool / query cache.
A common design uses multiple tiers: local in-process cache (L1) backed by a distributed cache (L2) backed by the database (origin). This is a near cache pattern.
Those six bullets are the taxonomy; each one deserves its actual reasoning, because "where do I put the cache" is a design decision with different trade-offs at every level and interviewers probe exactly here.
Client-side (browser / mobile app). The fastest cache possible — a hit costs zero network, zero server CPU, zero money, and works offline. It is also the only cache you cannot reach: once a response is stored on ten million devices, you have no mechanism to delete it. This asymmetry drives the whole design of HTTP caching (§7): you control client caches in advance, by declaring at write time how long the response may be reused and by giving it a validator the client can use to cheaply re-check. The standard mitigation for un-invalidatable client caches is content-addressed URLs — name the file app.9f3c2a.js, where the hash is of the contents, and cache it for a year; when the contents change, the name changes, so the old cached copy is simply never requested again. This is called cache busting, and it converts an invalidation problem into a naming problem.
CDN / edge cache. A shared cache in hundreds of locations near users, so a hit avoids not just the database but the entire cross-continent round trip (§1.2: ~150 ms saved). Because it is shared across all users, one user's miss warms the cache for everyone nearby — an enormous multiplier on popular content. You can invalidate it (purge APIs, in seconds for Fastly, tens of seconds for most). Costs: it caches only what is safely shareable, so anything personalized needs careful Vary handling or it will leak one user's data to another — a real and catastrophic bug class. Full treatment in File 05.
Reverse-proxy cache (NGINX, Varnish, Apache Traffic Server). The same idea, sitting in your own datacenter in front of your app tier. It absorbs repeated identical requests before they ever reach application code, which means it saves not just database time but your app's template rendering, serialization, and framework overhead — often the larger cost. Best at: anonymous, high-traffic, identical responses (a homepage, a product page for logged-out users). Costs: it caches whole HTTP responses, so it is coarse — you cannot reuse a fragment of it, and any personalization defeats it entirely. Mitigation: ESI/fragment caching, or splitting pages into a cacheable shell plus a personalized fetch.
Application-local (in-process) cache — Caffeine, Guava, Ehcache, or a plain hash map. The value lives in your process's own heap, so a hit is a pointer dereference: tens of nanoseconds, roughly 10,000× faster than a network hop to Redis, with no serialization at all (you cache the object, not bytes — §0.4). That is a staggering advantage, and it comes with three sharp costs. (1) Every server has its own copy, so N servers means N potentially-divergent versions of the truth and no way to invalidate them all short of a broadcast (§2.9). (2) It dies on restart and on every deploy, so it is permanently cold in a frequently-deployed service. (3) It competes with your application for heap, which in a garbage-collected language means a large local cache directly lengthens GC pauses — you can make your p99 worse by caching more. Reach for it when: the data is small, extremely hot, and tolerant of a few seconds of divergence — feature flags, configuration, reference data, the results of a hot lookup.
Distributed / remote cache — Redis, Memcached, and the family in §10. One network hop (~0.2–1 ms in-datacenter), which is 1,000× slower than local memory and 50–100× faster than the database. In exchange you get the properties local caching cannot give: one shared copy (so invalidation is a single delete rather than a broadcast), survival across app restarts and deploys, capacity far beyond one process's heap (a Redis cluster can hold terabytes), and the ability to hold shared state like sessions and locks. This is the default meaning of "add a cache" in an interview, and the reason is that shared-and-invalidatable beats fast-but-divergent for almost all data.
Database-internal cache — the buffer pool. Every database already caches: the buffer pool keeps recently-used disk pages in RAM, so a "disk read" is frequently not a disk read at all. This matters for two reasons. First, it means your measured "database is slow" may be a cold buffer pool problem, fixable by giving the database more RAM rather than by adding a cache tier. Second, it means your application-level cache and the database's buffer pool are caching the same bytes twice — a genuine waste that argues for caching computed results (a rendered page, an aggregated total) rather than raw rows, since only the former saves work the database cannot save for you.
The multi-tier / near-cache pattern, made concrete. L1 local (nanoseconds, per-process) → L2 distributed (sub-millisecond, shared) → origin (milliseconds, authoritative). A read checks L1, then L2, then the DB, populating on the way back up. The benefit is not just speed — it is blast-radius containment: if the Redis cluster dies, L1 keeps serving hot keys and the database sees a survivable fraction of traffic instead of 100% of it (§6.2). The cost is coherence: you now have N + 1 copies, and the L1 copies are the ones you cannot delete directly, which is why §2.9 exists. The standard rule: give L1 a much shorter TTL than L2 (say 5 seconds versus 5 minutes), so local divergence is bounded by seconds without a coherence protocol at all. That single trick avoids an enormous amount of complexity and is the right first answer.
2.5Sub-Concept: Redis vs. Memcached (the two default answers)#
- Memcached: dead-simple, multi-threaded, in-memory key→value (string/blob) store. Pure LRU cache. Great when you need a big, simple, fast cache and nothing more.
- Redis: single-threaded (per shard) in-memory data-structure server — supports strings, hashes, lists, sets, sorted sets, bitmaps, HyperLogLog (a probabilistic structure that counts unique items — e.g., unique visitors — in a few KB of memory instead of storing every ID, at the cost of ~1% error), streams, geospatial. Supports persistence in two forms — RDB (point-in-time snapshots of the whole dataset written to disk periodically: cheap, but you lose everything since the last snapshot on crash) and AOF (Append-Only File) (every write command is appended to a log and replayed on restart: far less loss, more disk I/O; this is a Write-Ahead-Log idea, File 11) — plus replication, pub/sub (publish/subscribe messaging: clients subscribe to named channels and receive every message published to them, useful for cross-node cache-invalidation broadcasts), transactions, Lua scripting, and clustering. The default choice unless you specifically only need Memcached's simplicity. When an interviewer says "add a cache," saying "Redis" and justifying it (data structures + persistence + replication) is the safe move.
Those two paragraphs are the interview-answer version. The full landscape — including Valkey, Dragonfly, KeyDB, Hazelcast, Caffeine, Varnish, ElastiCache, MemoryDB, DAX, Momento — with the architectural reasons they differ and the decision rules for choosing between them, is §10.
2.6Sub-Concept: The Working Set and Why Caches Work At All#
A cache only helps if requests are not uniformly distributed over the data. If every one of your ten million users were equally likely to be requested on any given second, a cache holding one hundred thousand of them would hit 1% of the time and be worthless. Caching works because real access is savagely skewed.
The working set is the set of data actually being accessed during some window of time — the "hot" subset. Real workloads follow a power law (often summarized as the Pareto or 80/20 rule, though real systems are usually far more extreme): a tiny fraction of items receives the overwhelming majority of requests. On a social network, a few thousand accounts out of hundreds of millions generate a large share of all profile views. On an e-commerce site, the front-page products dwarf the long tail. Zipf's law describes this formally: the frequency of the item ranked k is proportional to 1/k — the second-most-popular item gets about half the traffic of the first, the tenth gets about a tenth, and so on.
The practical consequence, with numbers. Under a Zipf distribution, caching the top 1% of items by popularity commonly captures 60–80% of all requests, and the top 10% captures well over 90%. That is why a cache holding a small slice of your data can carry the great majority of your traffic, and it is the quantitative answer to "how big should the cache be?" — big enough to hold the working set, which you determine by measurement, not by fraction-of-total-data.
The corollary that decides real designs: past the working set, additional cache memory buys very little. Doubling from 1% to 2% of the dataset might take you from 70% to 75% hit ratio; doubling again buys less. So the right sizing procedure is: plot hit ratio against cache size on real traffic, find the knee of the curve, and provision a little past it. Buying memory beyond the knee is spending money on the long tail, which by definition is not being reused.
**When caching does not work — say this in an interview, it is a strong signal:** if access is genuinely uniform (random-ID lookups, a security scanner enumerating your ID space), or if data is written far more often than read (each write invalidates before the value is reused), or if every request is unique (a search over a huge query space with no repeats), a cache adds latency, memory cost, and a consistency hazard while buying nothing. Recognizing a workload that should not be cached is as valuable as knowing how to cache one.
2.7Sub-Concept: Key Design and Namespacing#
Since a cache can only answer questions you turned into keys (§0.4), key design is cache design. It is also where sloppiness produces outages.
A key is a contract. user:42:profile says: the entity type, the identity, and the aspect being cached. The conventional format is colon-separated segments, most-general to most-specific — app:entity:id:aspect:version. Concretely: shop:product:9981:detail:v3. The reasons this matters:
- Collisions are silent and catastrophic. If one team caches
42 → user objectand another caches42 → order object, one of them will read the wrong type of data and behave in ways that are extremely hard to trace. Namespacing by entity type makes collision impossible by construction. - Prefixes give you operational leverage. With a consistent prefix you can measure hit ratio per feature, scan for a class of keys during an incident (with
SCAN, neverKEYS— see below), and reason about which prefix is consuming memory. - Versions in keys give you free invalidation.
…:v3means bumping the version instantly orphans every old key without deleting anything; the orphans age out under TTL and eviction. This is the versioning strategy of §5, and it is the cheapest correct invalidation you can buy.
Everything that varies the answer must be in the key. This sounds obvious and is the most common cache bug in production. If a response depends on the user's locale, currency, feature-flag bucket, device type, or authorization level, and those are not in the key, then user A will be served user B's data. The failure is intermittent, user-specific, and often a privacy incident rather than a mere bug. Write the key as a pure function of every input that affects the value, without exception.
Key length is a real cost. A cache with 100 million entries and 60-byte keys spends 6 GB on keys alone, before a single value. Keep keys short but never at the expense of clarity — u:42:p saves bytes and costs you every future debugging session. If you must compress, hash the variable part (user:9f3c2a…) rather than abbreviating the readable part.
**The KEYS command is a production outage waiting to happen.** KEYS user:* scans the entire keyspace in one blocking operation; on a server with fifty million keys this blocks Redis's single thread (§0.5) for seconds, during which every other request from every client stalls. Use SCAN, which returns a cursor and walks the keyspace incrementally, yielding between batches. This is the single most-repeated piece of Redis operational advice in existence, and it is repeated because people keep doing it.
2.8Sub-Concept: Hot Keys and Big Keys#
These are the two ways a single key destroys a cluster, and they are distinct problems with distinct fixes.
The hot key problem. A cache cluster distributes keys across nodes by hashing the key (File 06 covers exactly how). That works beautifully — until one key becomes far hotter than any other. A celebrity's profile, the front-page item, a flash-sale product, a global feature-flag document: one key means one hash means one node. That node now receives a hundred thousand requests per second while its peers idle. You cannot shard your way out of it, because the unit of sharding is the key. Symptoms are unmistakable once you know them: one node at 100% CPU with the rest at 5%, and latency for all keys on that node degrading because they queue behind the hot one.
The fixes, in order of how often they are the right answer:
- Cache the hot key locally (L1) in every app server, with a very short TTL (§2.4). Now the distributed cache sees one request per server per few seconds instead of the full flood. This is the standard answer, and it works because hot keys are by definition few enough to fit in local memory.
- Replicate the key under several names —
product:9981:copy0…copy9— and have each client pick one at random. The load spreads across ten nodes. Cost: invalidation must now delete all ten copies, and you must remember they exist. - Read from replicas. If the hot key's shard has read replicas, spread reads across them. Cost: replicas lag, so you have accepted extra staleness.
- Move it out of the cache entirely — some values (feature flags, configuration) belong in a push-based config system that every server holds in memory, not in a cache that everyone polls.
The big key problem. A single value that is enormous — a one-gigabyte list, a hash with ten million fields, a serialized blob of a whole catalogue. Three separate harms follow. (1) Blocking: Redis executes commands on one thread, so any operation touching that value — reading it, deleting it, serializing it for a replica — occupies the server for the entire duration, stalling every other client. Deleting a huge key with DEL can block for seconds; UNLINK exists precisely to free it in a background thread instead. (2) Network: every read ships the whole value across the network, so a 100 MB value read ten times a second is 8 Gbps of traffic from one key. (3) Imbalance: the node holding it uses vastly more memory than its peers, so cluster capacity planning becomes meaningless. The fix is always the same shape: split it — by field (use a hash and read single fields with HGET rather than HGETALL), by page (list:page:1, page:2), or by time bucket. Find them with redis-cli --bigkeys, which samples the keyspace and reports the largest key per type.
2.9Sub-Concept: Cache Coherence Across Tiers (the L1 invalidation problem)#
Once you put a local cache in every app server (§2.4), you have created copies you cannot delete with a single command, because they live inside N separate processes. Making those copies agree is the cache coherence problem — the same problem CPU designers solve in hardware for L1/L2/L3 caches, appearing again at the application layer.
Four strategies, from cheapest to most correct:
- Short TTL only (do this first). Give L1 a TTL of a few seconds. You have not solved coherence; you have bounded it — every copy is at most a few seconds divergent, and the system self-heals with no messaging at all. Benefit: zero complexity, no new failure modes. Cost: every server refetches every hot key every few seconds (fine — that is a trivial load on L2), and you must be able to tolerate a few seconds of divergence. For the overwhelming majority of data, you can, and this is the correct answer.
- Pub/sub invalidation broadcast. On a write, publish "key K changed" to a channel every app server subscribes to; each server deletes K from its local cache. Benefit: near-instant convergence (milliseconds). Cost: pub/sub in Redis is fire-and-forget — a server that is restarting, GC-pausing, or briefly disconnected simply misses the message and keeps serving stale data forever, with no way to detect it. Mitigation, and this is the important part: always pair the broadcast with a TTL, so a missed message costs you seconds of staleness rather than permanent staleness. Broadcast for speed, TTL for correctness.
- Client-side caching with server-assisted invalidation (Redis 6+ "tracking"). The Redis server remembers which client read which key and pushes an invalidation message to exactly those clients when it changes. Benefit: targeted, no broadcast storm, and it is a protocol feature rather than something you build. Cost: the server spends memory tracking the key→client table (there is a "broadcasting mode" with key prefixes that trades precision for memory), and the same delivery caveat applies on disconnect.
- Versioned/generation keys. Keep a
generationcounter for an entity class; include it in every key. A write bumps the generation, and every server's next lookup naturally uses a new key that nobody has cached. Benefit: correctness without any messaging, and no deletes at all. Cost: every server must read the current generation (itself a cached value, with its own TTL, so you have pushed the problem down a level — but to one key instead of millions, which is exactly the win).
2.10Sub-Concept: Negative Caching#
Negative caching is caching the absence of a result — storing "this key does not exist" as a first-class cached fact. It is the primary defence against cache penetration (§6.1), and it is worth understanding as its own idea rather than as a trick.
Why it is needed. The ordinary cache-aside loop caches values. A lookup that finds nothing has no value to cache, so it caches nothing, so the next identical lookup also misses and also hits the database. For non-existent data — a deleted user, a mistyped ID, an attacker enumerating the ID space — the cache is completely transparent and the database absorbs 100% of the traffic. That is not a degraded cache; it is no cache at all, precisely when you are under attack.
The mechanism. Store a sentinel value (an empty marker, a null token — anything your code can distinguish from a real value) under the key, with a deliberately short TTL. Now the second request for that ID hits the cache and returns "not found" without touching the database.
The trade-offs, which are real. (1) Memory: an attacker sending random IDs is now filling your cache with negative entries, potentially evicting genuinely useful data — you have converted a database-load attack into a cache-pollution attack. Mitigate with short TTLs (30–60 seconds is typical) and, where possible, a separate memory budget for negatives. (2) Correctness at creation: if you cache "user 500 does not exist" and user 500 is created two seconds later, you will serve "not found" for the rest of the TTL. Mitigate by explicitly deleting the negative entry on creation — an easy step to forget, which is why the TTL must be short enough that forgetting it is survivable. (3) You must distinguish "not found" from "the database was down." Caching a failure as a negative result is how a thirty-second outage becomes a thirty-minute one. Only cache a negative when the origin authoritatively said the item does not exist.
The complementary defence is the Bloom filter (§6.1), which rejects known-absent keys before consuming any cache memory at all — the two are used together, not as alternatives.
2.11Sub-Concept: Cache Warming and the Cold-Start Problem#
A cold cache is an empty one — after a deploy that restarts your app servers (killing every L1 cache), after a Redis failover or restart, after adding a new node to a cluster, or after a FLUSHALL that someone will one day type in the wrong terminal. Every request is a miss, and your database instantly receives the full, unabsorbed production read load — traffic it has never handled, because the cache has been absorbing 95% of it since the day it was deployed.
This is the cold-start problem, and it is genuinely dangerous rather than merely slow: it is the single most common way a cache outage becomes a full site outage. The chain is: cache empties → database receives 20× normal load → database latency climbs → application threads block waiting on the database → thread pools exhaust → the application stops serving everything, including requests that would have been cache hits. That is a cascading failure, and it is why §6.2's avalanche and this are the same underlying risk seen from two directions.
Warming strategies, with their costs:
- Preloading (batch warm). Before sending traffic, run a job that populates the cache with the known-hot keys — the top N products, active sessions, configuration. Benefit: the cache is useful from the first request. Cost: you must know what is hot (derive it from yesterday's access logs), and the preload itself is a burst of database load you must rate-limit.
- Traffic ramping / slow start. Bring the new instance into rotation at a small traffic weight and increase it gradually (this is §4.2 of File 01 — weighted routing — used for a cache reason). Benefit: the cache warms from real traffic, which is by definition the right traffic, and the database load is spread over minutes. Cost: slower deploys; needs load-balancer support.
- Replica promotion instead of restart. If your cache has replicas, fail over to an already-warm replica rather than restarting a node cold. Benefit: no cold window at all. Cost: you are running (and paying for) replicas.
- Persistence. Redis's RDB/AOF (§2.5) means a restarted node can reload its dataset from disk instead of starting empty. Benefit: warm after a planned restart. Cost: load time on a large dataset is minutes, and it does not help if the data itself was the problem.
- Never let it happen at once. The structural defence: do not restart your whole cache tier simultaneously, do not deploy all app servers at once, and keep the multi-tier design (§2.4) so one tier's cold start is absorbed by another.
The measurement that tells you whether you are exposed: deliberately test it. Take a staging environment with production-like traffic, flush the cache, and watch what the database does. If the answer is "it falls over," you do not have a caching layer — you have a database that cannot serve your traffic, with a cache hiding that fact.
3Deep-Dive Mechanics: The Caching Patterns#
The caching pattern (a.k.a. caching strategy) defines how reads and writes flow between the application, the cache, and the database. This is the heart of File 02. There are five canonical patterns. Master all five and their trade-offs.
3.1Cache-Aside (a.k.a. Lazy Loading) — the most common#
The application is in charge; the cache is a dumb key-value store beside the DB.
Read path:
1. App asks cache for key K.
2. HIT -> return value. Done.
3. MISS -> App reads K from database.
4. App writes K -> value into cache (usually with a TTL).
5. App returns value.Write path:
1. App writes new value to the DATABASE.
2. App INVALIDATES (deletes) key K from the cache (does NOT update it).
-> next read will miss, reload fresh from DB, and repopulate.- Pros: Only requested data is cached (lazy → memory-efficient). Cache failure is survivable (app can go straight to DB). Simple mental model. The default for read-heavy workloads.
- Cons: First request for any key is always a miss (cold-cache penalty). Risk of stale data if invalidation is missed. Subtle race conditions (see 3.6).
- Why delete-not-update on write? Updating the cache on write risks a race: two concurrent writers can interleave so the cache ends up with the older value permanently. Deleting the key is safer — worst case you just cause an extra miss. "Invalidate, don't update" is a key senior insight.
3.2Read-Through#
Like cache-aside, but the cache library/service itself knows how to load from the DB on a miss (the app only ever talks to the cache).
Read path:
1. App asks cache for K.
2. HIT -> return.
3. MISS -> the CACHE (via a loader function) fetches from DB,
stores it, and returns it. App never touches the DB directly.- Pros: Cleaner app code (data-access logic centralized in the cache layer). Consistent loading behavior.
- Cons: Requires a cache that supports a loader (or a library like Caffeine/Ehcache). Still has cold-miss penalty. Couples cache to DB schema.
- Cache-aside vs read-through: same read behavior; the difference is who owns the load logic — the app (cache-aside) or the cache layer (read-through).
3.3Write-Through#
On a write, the app writes to the cache, and the cache synchronously writes to the DB before acknowledging.
Write path:
1. App writes K=V to the cache.
2. Cache writes K=V to the DATABASE (synchronously).
3. Only after DB confirms, the write is acknowledged to the app.- Pros: Cache and DB are always consistent (cache is never staler than DB). Reads after writes are guaranteed fresh and fast (data is already in cache).
- Cons: Higher write latency — every write pays the DB write cost plus the cache write. Caches data that may never be read again (wasteful unless paired with read-through). Doesn't help write-heavy workloads.
- Typical pairing: write-through + read-through together give a cache that's always consistent and always warm for written keys.
3.4Write-Back (a.k.a. Write-Behind)#
On a write, the app writes to the cache only, which acknowledges immediately, and the cache asynchronously flushes to the DB later (batched).
Write path:
1. App writes K=V to cache. Cache ACKs IMMEDIATELY. (fast!)
2. Later, asynchronously (after a delay / in batches),
the cache flushes dirty entries to the DATABASE.- Pros: Extremely low write latency (write hits RAM, returns instantly). Absorbs write spikes and coalesces many writes to the same key into one DB write (great for counters, view counts, likes). Reduces DB write load massively.
- Cons: Risk of data loss — if the cache node crashes before flushing, the un-flushed writes are gone. Requires durability measures (replication, AOF persistence) if the data matters. More complex. Eventual (not immediate) DB consistency.
- Where used: high-write, loss-tolerant or loss-mitigated workloads — metrics, analytics counters, view/like counts, leaderboards. This is exactly how CPU write-back caches work, hence the name.
3.5Write-Around#
On a write, the app writes directly to the DB, bypassing the cache entirely. The cache is only populated later on a read miss (i.e., write-around is usually paired with cache-aside reads).
Write path:
1. App writes K=V straight to DATABASE. Cache is NOT touched.
2. (If K was cached, either invalidate it or let TTL handle staleness.)- Pros: Avoids flooding the cache with write-heavy data that won't be read soon (prevents "cache pollution" from bulk writes / one-time data).
- Cons: A read right after a write is a guaranteed miss (higher latency for recently-written data).
- Where used: workloads where written data is rarely read immediately (logging, historical/archival writes, bulk imports).
3.6The Read Path Race Condition (senior-level gotcha)#
Cache-aside has a famous concurrency bug:
Time →
Writer: updates DB (K = new) ............ then deletes cache key K
Reader: cache MISS, reads DB (K = old) ............... then writes cache K = oldIf the reader's DB read happens before the writer's DB update, but the reader's "populate cache" happens after the writer's "delete cache," the cache ends up holding the old value permanently (until TTL). Mitigations: short TTLs (bounds the damage), delete-again after a delay (e.g., delayed double-delete), versioned keys, or using a proper write-through pattern. Knowing this race exists is a strong senior signal.
3.7Pattern Selection Cheat Table#
| Pattern | Write latency | Consistency | Best for |
|---|---|---|---|
| Cache-Aside | Normal | Eventual (invalidation) | General read-heavy (default) |
| Read-Through | Normal | Eventual | Read-heavy + centralized load logic |
| Write-Through | High | Strong (cache≈DB) | Read-after-write freshness needed |
| Write-Back | Very low | Weak (loss risk) | Write-heavy, loss-tolerant (counters) |
| Write-Around | Normal | Eventual | Write-heavy, rarely-read data |
4Eviction Policies — Exhaustive Breakdown#
When the cache is full, which entry dies? This is a favorite interview topic (LRU implementation especially).
4.1LRU — Least Recently Used#
Evict the entry that hasn't been accessed for the longest time. Assumes temporal locality: recently used data is likely to be used again soon. The default policy in Memcached and a common Redis mode.
Implementation (know this — LeetCode-classic): a hash map (key → node) for O(1) lookup, combined with a doubly-linked list ordering nodes by recency. On access, move the node to the head (most-recent). On eviction, remove the tail (least-recent). Both operations O(1).
- Pro: simple, matches most real access patterns.
- Con: a one-time scan of many unique keys (e.g., a batch job reading everything once) pollutes the cache and evicts genuinely hot data ("cache pollution" / scan resistance problem).
4.2LFU — Least Frequently Used#
Evict the entry with the lowest access count. Assumes popularity is stable. Keeps items that are accessed often, not just recently.
- Pro: better than LRU when some items are perennially hot but accessed in bursts. Scan-resistant (a one-time scan has low frequency and gets evicted).
- Con: a formerly-hot item that cooled down stays "sticky" (high historic count) and won't evict — stale popularity. Fixed by aging / windowed counts (decaying frequencies over time). Redis implements LFU with a probabilistic, decaying counter.
4.3FIFO — First In First Out#
Evict the oldest-inserted entry regardless of access. Simple queue. Rarely ideal (ignores usage), but cheap.
Mechanism. A single queue. Insert at the tail; evict from the head. Reading an entry does nothing — no reordering, no metadata update, no bookkeeping of any kind. That is the whole algorithm.
Worked example (capacity 3), inserting A, B, C then accessing A then inserting D:
insert A, B, C -> queue: [A B C] (A is oldest)
access A -> queue: [A B C] unchanged — FIFO ignores reads
insert D -> evict A, queue: [B C D]Compare that with LRU on the same trace: LRU would have moved A to the most-recent position on access and evicted B instead. FIFO discarded the item that had just proven it was still wanted — and that single difference is FIFO's entire weakness.
Why anyone uses it anyway. Because reads cost nothing. LRU must mutate a shared linked list on every read, which in a multi-threaded cache means taking a lock on the hottest path in the system — the read path — turning your cache into a contention point. FIFO's read path is lock-free by construction. Complexity: O(1) insert, O(1) evict, zero per-access work, and less metadata per entry than LRU (no prev/next pointers to maintain on read).
- Pros: the cheapest possible policy; no read-path contention; trivially correct; predictable, bounded memory overhead.
- Cons: ignores the strongest available signal (that something was just used), so it evicts hot items purely for being old. On skewed workloads it is meaningfully worse than LRU — the popular item that has been in the cache longest is exactly the one FIFO throws away first.
- When to use: when per-access bookkeeping is genuinely too expensive (extremely high-throughput, multi-core caches), when entries are naturally uniform in value, or as the base of a modern hybrid (see S3-FIFO in §4.7, which has revived FIFO's reputation by pairing it with a small admission filter).
4.4Random Replacement#
Evict a random entry. Surprisingly not-terrible, needs no bookkeeping. Used where the metadata cost of LRU/LFU isn't worth it (some hardware caches).
Mechanism. Pick a slot uniformly at random; evict whatever is in it. No ordering structure exists at all.
Why it is not as bad as it sounds — the reasoning matters. Under a skewed (Zipf) workload (§2.6), hot items are a small fraction of entries but are re-inserted immediately after any unlucky eviction, while cold items are the majority and therefore are statistically more likely to be the random victim. Random replacement gets a surprising share of LRU's benefit for none of LRU's cost. Published comparisons typically put it a few percentage points of hit ratio behind LRU on realistic traces — worse, but not catastrophically so.
It also has a property LRU lacks: no pathological case. LRU can be defeated deterministically (a loop scanning N+1 items in a cache of size N produces a 0% hit ratio — every single access evicts the item that will be needed next). Random has no such adversarial trace, because there is no pattern to exploit. This is the same reasoning that makes randomized algorithms attractive elsewhere: predictable-average beats good-average-with-a-cliff when an adversary (or an unlucky access pattern) picks your input.
- Pros: zero metadata, zero read-path work, no locks, no pathological worst case, trivially parallel.
- Cons: leaves measurable hit ratio on the table; unpredictable per-item behaviour makes debugging ("why did this get evicted?") impossible.
- Complexity: O(1) everything, and the smallest memory overhead of any policy.
- When to use: hardware and CPU caches (where a linked list per line is unthinkable), extremely high-concurrency software caches where lock contention would cost more than the hit-ratio difference, and as a component of sampling-based approximations — Redis's own "LRU" is exactly this: it samples a handful of random keys and evicts the least-recently-used among the sample (§4.6), buying near-LRU quality at random-replacement cost.
4.5MRU — Most Recently Used#
Evict the most recently used. Counterintuitive, but useful in specific patterns (e.g., a file being read once sequentially — once you've read a block you won't need it again soon).
Mechanism. Identical structure to LRU — a recency-ordered list — but evict from the head (most recent) rather than the tail.
Why it can possibly be right. LRU assumes temporal locality: what was just used will be used again. Some workloads have the exact opposite property, and for those, LRU is not merely suboptimal but actively worst-possible. The canonical case is a repeated sequential scan of data larger than the cache. Trace it with a cache of size 3 over a repeating scan A B C D A B C D…:
LRU: cache [A B C], next request D -> evict A (oldest) -> cache [B C D]
next request A -> MISS (just evicted!) -> evict B -> cache [C D A]
next request B -> MISS (just evicted!) ...
every single access misses: 0% hit ratio.
MRU: cache [A B C], next request D -> evict C (newest) -> cache [A B D]
next request A -> HIT. next request B -> HIT.
retains a stable subset: ~50% hit ratio on this trace.LRU achieves zero on a trace where MRU achieves half, because in a cyclic scan the most-recently-used item is precisely the one you will need last, and the least-recently-used is the one you will need next. This trace is worth memorizing — it is the cleanest demonstration that no eviction policy is universally correct, only correct for an access pattern.
- Pros: optimal-ish for cyclic and sequential-scan workloads; identical implementation cost to LRU.
- Cons: catastrophic for ordinary skewed workloads (it evicts exactly the hot item you just fetched), which is why it is never a general-purpose default.
- Complexity: O(1) with hash map + doubly-linked list, same as LRU.
- When to use: database systems that know a query is a full table scan (many use MRU or scan-resistant handling for exactly this), sequential media streaming, and any workload where you can prove an item will not be revisited soon. Realistically, you name this in an interview to show you understand that eviction policy must match access pattern — not because you will implement it.
4.5b Clock / Second-Chance — LRU's approximation, and why real systems use it#
Almost every operating system's page cache uses this rather than true LRU, so it belongs in the taxonomy.
The problem it solves. True LRU requires updating a shared ordering structure on every read. In an OS page cache, a "read" is a memory access happening billions of times a second — you cannot take a lock and splice a linked list on each one. Clock gets LRU-like behaviour with a single bit of work per access.
Mechanism. Arrange all entries in a circular buffer with a hand (like a clock face). Each entry has one reference bit. On access, hardware or software sets that entry's reference bit to 1 — that is the entire per-access cost, one bit write, no lock, no pointer surgery. On eviction, the hand advances: if the entry under the hand has reference bit 1, clear it to 0 and move on (the entry gets a "second chance"); if the bit is already 0, evict that entry.
hand -> [A:1] [B:0] [C:1] [D:0]
need to evict:
A has bit 1 -> clear to 0, advance [A:0] [B:0] [C:1] [D:0]
B has bit 0 -> EVICT BWhy it approximates LRU. An entry accessed recently has its bit set, so it survives at least one full sweep of the hand. An entry not touched for a full revolution has a cleared bit and dies. The result orders roughly by recency at the granularity of "was this touched in the last sweep," which is enough.
- Pros: one-bit per-access cost, no locking on the read path, tiny metadata, and hit ratios close to true LRU on real workloads.
- Cons: coarser than LRU (it cannot distinguish "used one second ago" from "used one sweep ago"); eviction cost is amortized but can require sweeping many entries in one call.
- Complexity: O(1) access; eviction is amortized O(1) but worst-case O(n) in one sweep.
- When to use: whenever the per-access cost of true LRU is unaffordable — which is why Linux's page reclaim, most database buffer pools, and CPU-adjacent caches use Clock or a variant (CLOCK-Pro, which adds inter-reference-recency tracking to resist scans).
4.5c Segmented LRU, 2Q, and LRU-K — making LRU scan-resistant#
LRU's defining weakness (§4.1) is that a single access promotes an item to the most-protected position. A one-time scan therefore evicts your entire hot set. This family of policies all fix it the same way: require evidence of reuse before granting protection.
SLRU (Segmented LRU). Split the cache into a small probationary segment and a larger protected segment. New items enter probation. An item accessed again while in probation is promoted into the protected segment; items evicted from probation are simply gone. Protected items that fall off the end demote back to probation rather than being evicted outright. Effect: a scan's items enter probation, are never re-accessed, and are evicted from probation without ever touching the protected hot set. Cost: one extra list and a tuning knob (the probation/protected split, commonly 20/80). This is a component of W-TinyLFU (§4.7).
2Q. The same idea with an explicit ghost queue: a FIFO queue for first-time entries, a second LRU queue for entries seen at least twice, and a small list of keys only (no values) for recently-evicted entries, so a returning key can be recognized as "seen before" and admitted straight to the hot queue. Cost: three structures and two tuning parameters; the ghost list costs memory proportional to key size, not value size — a good trade.
LRU-K. Track the timestamps of the last K accesses (typically K=2) and evict by the K-th most recent access time. An item accessed once has no second access, so its K-th access time is effectively infinitely old and it is evicted first, no matter how recent that single access was. This is the cleanest formulation of the idea: one access is noise, two accesses are a signal. Cost: K timestamps per entry and a priority queue rather than a list, so eviction becomes O(log n) rather than O(1) — which is why the approximations above are more common in practice.
- When to use any of them: whenever your workload mixes user traffic with batch jobs, analytics scans, crawlers, or backups — which is nearly every real system. If you can only remember one sentence: "LRU is fooled by a single access; scan-resistant policies demand a second one."
4.6TTL-based / Volatile policies#
Redis offers policies like volatile-lru, volatile-ttl (evict the key with the shortest remaining TTL first), allkeys-lru, noeviction (reject writes when full). Choosing noeviction turns a full cache into write errors — a real outage source if misconfigured.
**All eight of Redis's maxmemory-policy values, since this is a concrete configuration decision you will actually make.** The policy applies once memory reaches maxmemory; the volatile-* family considers only keys that have a TTL set, while the allkeys-* family considers everything.
| Policy | Evicts | Use it when |
|---|---|---|
noeviction | Nothing — write commands return an error | This Redis is a database, not a cache, and silently losing data is worse than failing loudly. The dangerous default to choose by accident: your cache stops accepting writes and your app starts erroring, which looks like a Redis outage but is a config choice. |
allkeys-lru | Least-recently-used of everything | The standard cache setting. Use when every key is discardable. |
allkeys-lfu | Least-frequently-used of everything | Skewed popularity with bursty access — keeps perennial favourites that LRU would drop during a quiet spell. |
allkeys-random | A random key | Uniform access, or when eviction cost must be minimal (§4.4). |
volatile-lru | LRU among keys with a TTL | Mixed workloads: persistent keys (sessions, config) have no TTL and are protected; cacheable keys have TTLs and are evictable. This is the setting that expresses "these keys matter, those do not." |
volatile-lfu | LFU among keys with a TTL | Same protection, frequency-based choice. |
volatile-random | Random among keys with a TTL | Same protection, cheapest choice. |
volatile-ttl | The key with the shortest remaining TTL | Evict what was going to expire soonest anyway — the least-wasteful eviction when TTLs genuinely reflect value. |
**The volatile-* trap you must know:** if you select a volatile-* policy and no keys have TTLs, Redis has nothing eligible to evict and behaves exactly like noeviction — writes start failing while memory sits full of keys it refuses to touch. This is a classic 3 a.m. incident, and the diagnosis is one line: check whether your keys actually have TTLs (TTL key returns -1 for "no expiry").
Redis's LRU is not LRU, and its LFU is not LFU. True LRU needs a global ordering structure; Redis refuses to pay for it. Instead, on each eviction it **samples maxmemory-samples random keys** (default 5) and evicts the best candidate from the sample, keeping a small pool of good candidates across calls. With 5 samples it approximates LRU closely; with 10 it is nearly indistinguishable from true LRU at modestly more CPU. This is §4.4's random-sampling insight applied deliberately. Likewise the LFU mode stores an 8-bit approximate counter per key that increments probabilistically (the higher the counter, the less likely a further increment — a logarithmic counter, so 8 bits can represent very large frequencies) and decays over time according to lfu-decay-time, which is how it solves the stale-popularity problem of §4.2. Knowing that these are approximations — and why the approximation is the right engineering choice — is a strong senior signal.
4.7Modern hybrids#
- W-TinyLFU (used by Caffeine, the best-in-class Java cache): a small admission window + a frequency sketch to decide whether a new item is "worthy" of admission, combined with segmented LRU. The sketch is a Count-Min Sketch — a Bloom-filter cousin (§6.1) that tracks approximate counts instead of membership: a small 2-D array of counters where each item hashes to one counter per row and increments it; to read an item's count, take the minimum across its counters (collisions only inflate, so the min is the tightest estimate). This lets the cache know "how frequently was this key accessed?" for millions of keys in a few hundred KB, without storing per-key counters. New item wins admission only if its estimated frequency beats the eviction candidate's. Achieves near-optimal hit ratios and scan resistance. If asked "best general-purpose modern eviction," this is the sophisticated answer.
- ARC (Adaptive Replacement Cache): balances recency and frequency adaptively; used in ZFS.
How ARC actually adapts, because "adaptively" is doing a lot of work in that sentence. ARC keeps four lists: T1 (items seen once — recency), T2 (items seen twice or more — frequency), and two ghost lists B1 and B2 holding the keys only (no values, so they cost almost nothing) of items recently evicted from T1 and T2 respectively. The adaptation is the elegant part: a hit in a ghost list is a signal that you sized wrongly. If a request hits in B1 — meaning "I evicted a recency item and immediately wanted it back" — ARC grows T1 at T2's expense. If it hits in B2, it grows T2 instead. The cache continuously re-tunes its own recency/frequency balance from live evidence, with no configuration knob at all.
- Pros: near-optimal across widely varying workloads; self-tuning; scan-resistant (a scan fills T1 and never reaches T2).
- Cons: four lists of bookkeeping; it was patented by IBM, which is precisely why Linux does not use it and why alternatives like CLOCK-Pro and W-TinyLFU were developed. That patent history is a nice detail to know.
- When to use: you generally do not implement it — you get it by using ZFS or PostgreSQL-adjacent systems that ship an ARC variant.
- S3-FIFO (2023) — the modern surprise. Recent research showed that three FIFO queues can beat LRU-based designs: a small FIFO (~10% of capacity) absorbing all new entries, a main FIFO for entries that proved themselves by being re-accessed while in the small queue, and a ghost queue of evicted keys. Because it is FIFO all the way down, there is no per-access list surgery and therefore no lock contention on the read path (§4.3's advantage), yet it achieves hit ratios competitive with or better than LRU and W-TinyLFU on many production traces. The insight: most cached objects are accessed exactly once ("one-hit wonders"), so the correct strategy is to get them out quickly rather than to rank them carefully. When to use: high-concurrency caches where lock contention, not hit ratio, is your limit. Naming it signals you follow current work rather than 1990s textbooks.
- Belady's optimal algorithm (OPT/MIN) — the theoretical ceiling. Evict the item that will be needed furthest in the future. It is provably optimal and impossible to implement, because it requires knowing the future. Its value is as a benchmark: when you replay a production trace offline, you do know the future, so you can compute what the optimal hit ratio would have been and measure how much your real policy leaves on the table. If LRU gets 85% and OPT gets 88%, stop tuning your eviction policy — the remaining gap is not where your problem is. That measurement is how you decide whether eviction work is worth doing at all.
4.8Comparison Table#
| Policy | Metadata / entry | Per-access cost | Eviction cost | Scan-resistant? | Adapts to workload? | Best for |
|---|---|---|---|---|---|---|
| LRU | prev/next pointers | O(1) + list mutation (lock) | O(1) | ❌ No | ❌ | General default; strong temporal locality |
| LFU | counter | O(1) counter bump | O(1)–O(log n) | ✅ Yes | ❌ (needs aging) | Stable popularity, bursty access |
| FIFO | queue position | Zero | O(1) | ❌ No | ❌ | Contention-limited caches; uniform value |
| Random | None | Zero | O(1) | Partially | ❌ | Hardware caches; no pathological case |
| MRU | prev/next pointers | O(1) | O(1) | n/a | ❌ | Cyclic/sequential scans only |
| Clock / second-chance | 1 bit | 1 bit write, lock-free | Amortized O(1) | ❌ (CLOCK-Pro: ✅) | ❌ | OS page caches, buffer pools |
| SLRU / 2Q / LRU-K | segment tag or K timestamps | O(1) (O(log n) for LRU-K) | O(1)–O(log n) | ✅ Yes | ❌ | Mixed user + batch traffic |
| W-TinyLFU | sketch (shared, ~KB) | O(1) sketch increment | O(1) | ✅ Excellent | Partially (windowed) | Best general-purpose modern default |
| ARC | 4 lists + ghosts | O(1) | O(1) | ✅ Yes | ✅ Fully self-tuning | Varying workloads (ZFS); patent-encumbered |
| S3-FIFO | queue position + ghost | Zero (lock-free) | O(1) | ✅ Yes | ❌ | High-concurrency, one-hit-wonder-heavy |
| Belady OPT | — | — | — | ✅ | — | Not implementable — offline benchmark only |
4.9The Decision Rule#
Say this, in this order:
- Default to LRU (or your platform's approximation of it — Redis's sampled LRU, an OS Clock). It matches the temporal locality of most real traffic, everyone understands it, and it is what every system ships. You need a reason to deviate, not a reason to choose it.
- Switch to LFU when popularity is stable but access is bursty — a catalogue where the same items are hot all month but each is requested in clusters. LRU will drop a perennial favourite during a quiet ten minutes; LFU remembers. Make sure it ages (Redis's
lfu-decay-time), or yesterday's viral item will occupy your cache forever. - Switch to a scan-resistant policy (W-TinyLFU, SLRU, 2Q) the moment batch jobs, analytics, crawlers, or backups share the cache with user traffic. This is the most common real-world reason to move off plain LRU, and the symptom is unmistakable: hit ratio collapses on a schedule.
- Choose FIFO, Random, or S3-FIFO when the bottleneck is contention rather than hit ratio — when profiling shows threads queueing on the cache's own lock, a policy with a free read path beats a smarter one with a contended path. Three points of hit ratio are not worth a serialized read path.
- Choose MRU only when you can prove the access pattern is cyclic or scan-like, and never as a default.
- In Redis specifically:
allkeys-lrufor a pure cache;volatile-lruwhen the same instance also holds keys that must not be evicted;allkeys-lfufor stable-popularity workloads; **nevernoevictionunless the instance is deliberately a database** — and if you pick avolatile-*policy, verify your keys actually have TTLs. - Before tuning any of this, measure against Belady OPT on a real trace. If the gap between your current policy and the theoretical optimum is small, your hit-ratio problem is a cache size problem or a key design problem (§2.7), not an eviction problem — and you will save weeks by knowing that.
5Cache Invalidation & Consistency#
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
Invalidation is removing/updating cached data when the underlying source changes, so you don't serve stale data. Strategies:
- TTL expiry (passive): simplest — just let entries expire. Accepts bounded staleness (up to TTL). Great when slightly-stale is fine (most content).
- Explicit invalidation on write (active): on every DB write, delete the affected cache keys. Precise but hard — you must know every key affected by a write, including derived/aggregated keys (e.g., a user's profile change might invalidate their profile key, their friends' feed keys, a search index...). This "fan-out invalidation" is where bugs live.
- Write-through: keeps cache in lockstep with DB (no separate invalidation needed).
- Versioning / key-namespacing: embed a version in the key (
user:42:v7); a write bumps the version, instantly orphaning all old-version keys (they expire naturally). Avoids having to find and delete them. - Event-based / CDC invalidation: the database emits change events (via Change Data Capture — reading the DB's replication log) to a stream (Kafka), and a consumer invalidates the corresponding cache keys. Decouples invalidation from application write code. (Ties into Files 07 and 08.)
Consistency reality: a cache introduces a second copy of data, so by definition you now have a consistency problem. Most caching accepts eventual consistency (the cache may briefly lag the DB). If you truly need read-your-writes or strong consistency, use write-through, or bypass the cache for critical reads, or version aggressively. State your consistency choice explicitly in interviews.
6The Three Cache Killers: Penetration, Avalanche, Stampede#
These three failure modes are the advanced caching interview questions. Know all three cold.
6.1Cache Penetration#
Problem: Requests for data that doesn't exist (in cache or DB). Every such request misses the cache and hammers the DB. Attackers exploit this by requesting random non-existent IDs (user id = -99999), and since the DB also returns "not found," nothing ever gets cached — every malicious request reaches the DB. The cache provides zero protection.
Solutions:
- Cache the null result. Store a sentinel "this key doesn't exist" with a short TTL. Now repeated requests for the missing key hit the cache. (Costs memory; use short TTL.)
- Bloom filter (sub-concept below). Keep a Bloom filter of all keys that do exist. Before querying, check the filter; if the filter says "definitely not present," reject immediately without touching cache or DB.
Sub-Concept: Bloom Filter. A Bloom filter is a space-efficient probabilistic data structure that answers "is X possibly in the set, or definitely not?" It's a bit array of
mbits pluskindependent hash functions. To add X, hash itkways and set thosekbits. To query X, check thosekbits: if any is 0, X is definitely not in the set (no false negatives); if all are 1, X is probably in the set (false positives possible). It never says "no" wrongly, so it's perfect for "should I even bother querying?" It uses a tiny fraction of the memory of storing the actual keys. Trade-off: you can't delete easily (standard Bloom filters), and false-positive rate rises as it fills.
6.2Cache Avalanche#
Problem: A large number of keys expire at the same time (e.g., you loaded a million keys at startup all with TTL=3600, so they all expire in the same second), OR the cache cluster goes down entirely. Suddenly a flood of misses all hit the DB simultaneously → the DB is crushed → cascading failure.
Solutions:
- TTL jitter: add a random spread to TTLs (
TTL = base + random(0, spread)) so expirations are staggered, never synchronized. Single most important fix. - High availability for the cache itself: replication + failover so the cache doesn't vanish wholesale. In Redis terms: Redis Sentinel is a set of watchdog processes that monitor a primary/replica pair, agree among themselves (a quorum vote, File 08) that the primary is dead, and automatically promote a replica — failover for a single Redis instance's data. Redis Cluster goes further: it shards the keyspace across many primaries (16,384 hash slots), each with its own replicas and built-in failover, so you get horizontal capacity and HA. Either way the point is the same: the cache is load-bearing infrastructure now, so it needs the same redundancy discipline as the database.
- Circuit breaker / rate limiting in front of the DB so a miss storm can't fully overload it (Files 03, 10).
- Multi-tier caching (local L1 cache absorbs some load if L2 dies).
6.3Cache Stampede (a.k.a. Thundering Herd / Dogpile)#
Problem: A single very hot key expires. In the instant it's gone, thousands of concurrent requests all miss simultaneously, and all of them try to recompute/refetch the same value from the DB at once — a "dogpile" that spikes the DB, even though they all want the identical value.
Solutions:
- Mutex / lock ("cache lock"): only the first miss acquires a lock and recomputes; everyone else waits (or briefly serves stale) until it repopulates. Prevents redundant recomputation.
- Request coalescing / single-flight: the app layer detects concurrent identical misses and lets only one in-flight fetch proceed, sharing its result with all waiters (Go's
singleflight, Go stdlib pattern). - Probabilistic early expiration (XFetch): refresh the key before it actually expires, with a probability that increases as expiry nears, so one lucky request refreshes it early while others still get the cached value — no synchronized cliff.
- Stale-while-revalidate: serve the stale value immediately while asynchronously refreshing in the background. Users never wait; the DB sees one refresh, not a storm.
Distinction to keep straight: Penetration = data doesn't exist. Avalanche = many keys die together / cache down. Stampede = one hot key dies and everyone rushes it. Interviewers deliberately test whether you can tell these apart.
7HTTP Caching & Client-Side Semantics#
Everything so far has been your cache — one you deployed and control. But the single largest cache in front of your system is one you do not own: the tens of millions of browser caches, mobile HTTP caches, corporate proxies, and CDN edges that will store your responses whether you thought about it or not. HTTP has a complete, standardized caching protocol (RFC 9111), and it is a canonical part of this topic — an interview answer about caching that never mentions Cache-Control or ETag has a hole in it. This section teaches that protocol from scratch.
7.1The two questions every HTTP cache asks#
An HTTP cache holding a stored response must answer two questions, and every header below serves one of them:
- "May I reuse this without asking?" — the freshness question. If the response is still fresh, the cache serves it with zero network traffic. This is a true hit.
- "Is my copy still correct?" — the validation question. If the response is stale, the cache may ask the origin "has this changed since the version I hold?" If the answer is no, the origin replies 304 Not Modified — a tiny header-only response with no body. This is not a free hit (it costs a round trip) but it saves the bytes, which for a 2 MB image is most of the cost.
Freshness avoids the round trip; validation avoids the payload. Real systems use both.
7.2Cache-Control — every directive that matters#
Cache-Control is the primary header, sent by the origin on a response (and sometimes by the client on a request). Its directives split into three groups.
Who may store it:
- **
public** — any cache may store this, including shared caches (CDNs, proxies). Use for genuinely shared content. - **
private** — only the end user's own cache may store it; shared caches must not. This is the header that prevents a CDN from serving user A's account page to user B, and forgetting it is a real, severe privacy incident, not a performance bug. - **
no-store** — do not write this to disk or memory anywhere, ever. For genuinely sensitive responses (banking detail, one-time tokens). This is the only directive that actually means "do not cache." - **
no-cache— the most misnamed directive in HTTP.** It does not mean "do not cache." It means "you may store it, but you must revalidate with the origin before every reuse." It ismax-age=0, must-revalidatein spirit. If you meant "never store this," you wantedno-store. Interviewers ask this specifically because so many engineers get it backwards.
How long it stays fresh:
- **
max-age=N** — the response is fresh for N seconds after it was generated. This is the TTL of §2.2, expressed on the wire. - **
s-maxage=N— same, but only for shared caches** (CDNs/proxies), and it overridesmax-agefor them. This is how you say "browsers may hold it 60 seconds, but my CDN may hold it an hour" — a very common and useful split, because you can purge the CDN and cannot purge browsers. - **
Expires: <date>** — the HTTP/1.0 predecessor, an absolute timestamp.Cache-Control: max-agewins where both are present. Still emitted for ancient-client compatibility; relative ages are strictly better because they are immune to clock skew between client and server.
What happens when it goes stale:
- **
must-revalidate— once stale, the cache must not** serve it without checking. Without this, caches are permitted to serve stale content in some conditions (e.g., when the origin is unreachable). Use it when serving stale data is a correctness violation rather than a minor annoyance. - **
stale-while-revalidate=N— serve the stale copy immediately and** refresh it in the background, for up to N seconds past expiry. This is exactly the stale-while-revalidate stampede defence of §6.3, standardized as an HTTP header — the user never waits, and the origin sees one refresh instead of a herd. - **
stale-if-error=N— if the origin is down or returns a 5xx, keep serving the stale copy for up to N seconds rather than showing an error. This is free resilience**: your CDN keeps your site up through an origin outage, serving slightly-old content instead of a failure page. It is one of the highest-value, lowest-effort headers in existence and it is chronically underused. - **
immutable** — this response will never change at this URL, so do not even revalidate on a user-initiated reload. Pair it with content-hashed filenames (§2.4):Cache-Control: public, max-age=31536000, immutableonapp.9f3c2a.jsis the canonical modern static-asset policy — cache for a year, never check, and rely on the URL changing when the content does.
7.3Validators — ETag and Last-Modified#
A validator is a token the origin attaches to a response so that a cache can later ask "still current?" cheaply.
**ETag (entity tag)** is an opaque identifier for a specific version of a resource — typically a hash of the content, e.g. ETag: "9f3c2a1b". On revalidation the cache sends If-None-Match: "9f3c2a1b"; the origin compares, and either returns 304 Not Modified (empty body — the cache keeps and refreshes its copy) or 200 with the new content and a new ETag. Strong vs weak: ETag: "abc" asserts byte-for-byte identity; ETag: W/"abc" (weak) asserts only semantic equivalence, which is what you want when a response is regenerated with a different timestamp or whitespace but the same meaning. Strong ETags are required for range requests (resuming a partial download) to be safe — a client resuming byte 5,000,000 of a file must be certain it is the same file.
**Last-Modified** is the cruder alternative: a timestamp. The cache sends If-Modified-Since: <date> and gets 304 or 200. Its weaknesses are why ETag exists: it has one-second granularity (so two changes in the same second are indistinguishable), it cannot express "changed and then changed back to identical content," and it depends on server clocks. Use ETag where you can; send both if you want maximum compatibility.
The cost of ETags nobody mentions: in a load-balanced fleet (File 01), every server must generate the same ETag for the same content, or a client hitting a different server on revalidation gets a spurious 200 with a full body and your bandwidth savings evaporate. If your ETag derives from an inode number, a build timestamp, or anything machine-local, you have built exactly that bug. Derive it from the content.
7.4Vary — the header that prevents serving the wrong user's data#
A shared cache keys stored responses by URL. But the same URL can legitimately produce different responses depending on request headers — a different language, a different compression, a different device layout. **Vary tells the cache which request headers are part of the cache key.**
Vary: Accept-Encoding means "store gzip and brotli variants separately." Vary: Accept-Language means "store per language." **Vary: Cookie means "store per cookie value"** — which, since cookies are per-user, effectively disables shared caching entirely, because every user has a distinct key.
Two failure modes, both severe and both common:
- **Missing
Vary** → a cache stores the English response and serves it to French users, or stores a gzipped body and hands it to a client that cannot decompress it. In the worst case — a personalized response cached withoutVaryorprivate— one user's data is served to another. - **Over-broad
Vary** (e.g.Vary: User-Agent, which has effectively unlimited distinct values) → the cache key fragments into thousands of variants, hit ratio collapses toward zero, and you are paying for a cache that never hits. Normalize instead: bucket user agents into two or three classes at the edge and vary on that.
7.5Where this connects#
Every mechanism in §3–§6 reappears here in protocol form: max-age is TTL (§2.2), stale-while-revalidate is the stampede defence (§6.3), stale-if-error is graceful degradation (§8.2), Vary is key design (§2.7), and immutable + content hashing is the client-cache invalidation answer from §2.4. The one thing HTTP caching cannot give you is invalidation — you cannot reach into a browser cache — which is why the correct posture is: short freshness for HTML, long freshness plus content-hashed URLs for assets, and private on anything user-specific. File 05 (CDNs) picks this up from the edge's perspective.
8Pros, Cons & Trade-offs#
8.1Benefits#
- Latency: 10–1000× faster reads.
- Throughput / DB offload: absorbs the bulk of read traffic; the DB only sees misses + writes.
- Cost: RAM caching is far cheaper than scaling the database to serve the same read volume.
- Enables statelessness: shared cache = session store for stateless app tier (File 01 payoff).
8.2Costs & Trade-offs#
- Consistency risk: a second copy of the truth → staleness, races, hard invalidation.
- Added complexity & failure modes: penetration/avalanche/stampede, eviction tuning, TTL tuning.
- Cold start: an empty cache after deploy/restart = all misses = DB spike ("cache warming" needed).
- Memory cost & capacity planning: RAM is finite and pricier per GB than disk.
- Operational: running a Redis/Memcached cluster (replication, failover, sharding, monitoring hit ratio, memory fragmentation).
- Debugging pain: "works for some users" bugs from partially-stale caches are notoriously hard.
9Interview Diagnostic Framework (Symptom → Solution)#
| System Symptom | Why caching is the cure | Specific solution |
|---|---|---|
| "The database is the read bottleneck / CPU pegged on reads." | Offload repeated reads to RAM. | Cache-aside with Redis in front of the DB. |
| "Same expensive query/computation runs over and over." | Reuse the result. | Cache the computed result with a TTL. |
| "Read-after-write must show fresh data instantly." | Cache must not lag DB. | Write-through (or invalidate-on-write + read-through). |
| "Write latency is too high / write spikes crush the DB." | Absorb & batch writes in RAM. | Write-back for loss-tolerant data (counters/metrics). |
| "Attackers query random non-existent IDs and melt the DB." | Misses bypass cache. | Cache nulls + Bloom filter (penetration defense). |
| "Every night at midnight the DB spikes and falls over." | Synchronized TTL expiry. | TTL jitter + cache HA (avalanche defense). |
| "One viral item's cache expiry causes a DB spike." | Hot-key dogpile. | Mutex/single-flight + stale-while-revalidate (stampede defense). |
| "Cache keeps evicting our hot data during batch jobs." | Scan pollution under LRU. | LFU / W-TinyLFU (scan-resistant eviction). |
| "Sessions break when a user hits a different server." | Local state, not shared. | Move session state into a shared distributed cache. |
10Real Implementations & Product Landscape#
"Add a cache" is not an implementation. This section is the catalogue: what each real caching product is, the architectural decision that distinguishes it from its siblings, what it is genuinely best at, and which one to pick when. §2.5 gave the two-line interview answer; this gives the actual landscape.
10.1Family 1 — Remote in-memory data stores (the "distributed cache" tier)#
Memcached. Released in 2003 by Brad Fitzpatrick for LiveJournal, and still the most-deployed cache you never hear about. Its defining property is deliberate minimalism: a flat key → opaque-blob store, nothing else. No data structures, no persistence, no replication, no cluster protocol — the client library decides which server holds which key (by consistent hashing, File 06), and the servers do not know about each other at all. That shared-nothing design is why it scales so cleanly: adding a server requires no coordination whatsoever.
Its second distinguishing property is that it is multi-threaded, so one Memcached instance uses every core on the box. Redis (classically) does not. On a 16-core machine serving a simple get/set workload, this is a real and measurable throughput advantage.
Its memory management is slab allocation: memory is carved into pages assigned to size classes (a 96-byte class, a 120-byte class, and so on, growing by a configurable factor), and each item is stored in the smallest class that fits. Benefit: no external fragmentation and O(1) allocation — critical for a process running for months. Cost: internal fragmentation (a 100-byte value in a 120-byte slot wastes 20 bytes) and slab calcification — once pages are assigned to a size class they are not easily reassigned, so a workload whose value sizes change over time can end up with memory stranded in the wrong classes and evictions happening while memory sits free. That is Memcached's signature operational surprise.
- Best at: large, simple, extremely high-throughput caches of similarly-sized values; multi-core utilization; predictable behaviour.
- Limits: LRU only; no persistence (a restart is a guaranteed cold start, §2.11); no replication or failover; values capped at 1 MB by default; no atomic operations beyond
incr/decr/cas, so the patterns of §0.5 are harder. - Reach for it when: you want a pure cache, you have many cores, values are uniform, and every additional feature is a liability rather than an asset.
Redis. Released in 2009 by Salvatore Sanfilippo, and the default answer for good reason: it is not a cache but an in-memory data-structure server that is very frequently used as a cache. The architectural choice that defines it is a single-threaded command execution loop — every command runs to completion with no other command interleaving. That is the source of both its greatest strength and its sharpest limitation. The strength: every operation is atomic for free (§0.5), which is what makes Redis usable for counters, locks, rate limiters (File 03), and queues without any locking protocol. The limitation: one slow command blocks everything — a KEYS * (§2.7), a big-key DEL (§2.8), or a slow Lua script stalls every client on that shard. (Redis 6+ added threaded I/O for reading and writing sockets, and Redis 7 continues that, but command execution remains single-threaded by design.)
What you actually get on top of get/set: strings, hashes, lists, sets, sorted sets (the structure behind every leaderboard and every sliding-window rate limiter), bitmaps, HyperLogLog (§2.5), streams (a durable log, File 07), geospatial indexes (File 16), pub/sub (§2.9), Lua scripting, transactions, persistence (RDB snapshots and AOF, §2.5), replication, Sentinel (automated failover for a primary/replica pair, §6.2) and Cluster (16,384 hash slots sharded across primaries, §6.2).
- Best at: everything that is "a cache plus one more thing" — sessions, leaderboards, rate limits, locks, queues, deduplication, geo-queries — and at surviving a restart warm.
- Limits: single-threaded execution per shard means one core per shard (you scale by running more shards, which turns a simple deployment into a cluster); memory overhead per key is higher than Memcached's; Redis Cluster does not support multi-key operations across slots unless you force keys together with hash tags (
{user42}:profile), which is a real constraint on data modelling. - Reach for it when: you need atomicity, data structures, persistence, or replication — which is most of the time. Not when: your workload is genuinely just get/set on uniform values at maximum multi-core throughput, where Memcached is simpler and faster per box.
Valkey. In March 2024 Redis Ltd. changed Redis's licence away from open source (to RSALv2/SSPL); the Linux Foundation forked the last BSD-licensed version as Valkey, backed by AWS, Google, and Oracle, and it is now the drop-in open-source continuation, wire-compatible with Redis and adding multi-threaded execution work of its own. You need to know this because the "just use Redis" answer now has a licensing dimension: managed services from major clouds have moved to Valkey, and if your organization cares about open-source licensing, Valkey is the answer to the same requirements. Naming this in 2026 signals you are current.
Dragonfly and KeyDB attack Redis's single-thread limit directly while staying wire-compatible, so existing clients work unchanged. KeyDB is a multi-threaded Redis fork. Dragonfly is a ground-up rewrite using a shared-nothing thread-per-core architecture with a modern hash table and a novel snapshotting algorithm, claiming order-of-magnitude throughput improvements on a single large machine. The trade you are making: far better vertical scaling and simpler operations (one big node instead of a cluster) in exchange for a smaller ecosystem, less battle-testing, and dependence on a smaller vendor. Reach for them when you have a genuine single-node throughput ceiling and would otherwise be forced into cluster complexity.
Hazelcast, Apache Ignite, Infinispan are the JVM-native distributed caches / in-memory data grids. They embed inside your Java processes (or run standalone), replicate and partition data across the cluster automatically, and add compute-near-data features (distributed execution, continuous queries, SQL over cached data). Best at: JVM shops wanting a cache that is also a distributed data structure library, with strong consistency options. Limits: heavy, JVM-only in practice, and operationally more complex than Redis. Reach for them when you are deep in a Java ecosystem and need embedded plus distributed in one; not when a language-neutral cache serving a polyglot fleet is the requirement.
10.2Family 2 — In-process (local / L1) caches#
Caffeine (Java). The current best-in-class local cache, and the reference implementation of W-TinyLFU (§4.7). It achieves near-optimal hit ratios, does its bookkeeping asynchronously in ring buffers so the read path is nearly lock-free, and supports size-based, time-based, and reference-based eviction plus automatic loading (read-through, §3.2) and refresh-ahead. Reach for it when you are writing JVM code and want an L1 cache — it is essentially the default, having superseded Guava Cache (its predecessor by the same author, still widely deployed, simpler, LRU-ish, and meaningfully slower).
Ehcache is the older JVM standard, notable for tiered storage (heap → off-heap → disk), which lets you hold far more than heap allows without GC pressure — its real differentiator, since a large on-heap cache directly worsens GC pauses (§2.4).
Outside the JVM: **lru_cache / cachetools in Python, go-cache/ristretto** in Go (Ristretto being explicitly TinyLFU-inspired), and in Node the lru-cache package. The common warning applies to all: an in-process cache in a multi-process server (Python/PHP under a pre-fork model, Node with cluster) is one cache per worker, multiplying memory use and divergence in a way people consistently under-estimate.
10.3Family 3 — HTTP / reverse-proxy caches#
Varnish is a caching HTTP reverse proxy whose distinguishing feature is VCL (Varnish Configuration Language) — a small domain-specific language, compiled to native code, in which you write the caching logic itself: how to build a cache key, when to bypass, how to handle cookies, how to purge. That programmability is why it powers large media sites. It also does request coalescing natively, so a stampede on a hot URL (§6.3) collapses into one origin fetch automatically. Limits: historically no TLS termination (you front it with something that does, File 01), memory-focused, and VCL is a real language to learn. Reach for it when you are caching HTTP responses at high volume with non-trivial rules.
**NGINX proxy_cache** gives you a competent disk-backed HTTP cache inside the load balancer you already run (File 01 §9.2), with proxy_cache_lock for coalescing and proxy_cache_use_stale implementing stale-if-error (§7.2). Reach for it when you already run NGINX and want caching without a new component — which is very often the right call.
Apache Traffic Server is the heavy-duty, disk-optimized option used by large CDNs; Squid is the veteran forward proxy, still relevant for outbound/corporate caching rather than serving your users.
10.4Family 4 — Managed cache services#
AWS ElastiCache is managed Redis/Valkey or Memcached: it handles provisioning, patching, backups, and failover, and is the default choice on AWS. AWS MemoryDB looks similar but is a different product: it is a Redis-compatible durable database that writes to a multi-AZ transaction log, so it is the answer when the in-memory data is the source of truth and cannot be lost — at meaningfully higher cost than ElastiCache. Confusing these two is a real design error: using ElastiCache where you needed durability loses data on failover; using MemoryDB as a plain cache overpays substantially. Amazon DAX is a third, narrower product — a write-through cache purpose-built for DynamoDB, sitting transparently in front of it and turning single-digit-millisecond reads into microsecond ones without any application-level cache-aside code.
Google Memorystore, Azure Cache for Redis, and Upstash (serverless, per-request pricing, HTTP API — notable because it works from edge/serverless runtimes that cannot hold persistent TCP connections) round out the managed options. Momento is a serverless cache API with no instances to size at all.
The universal trade with managed caches: you buy away the operational burden (failover, upgrades, monitoring) and give up some control (restricted commands, fixed configuration, no custom modules) at a price premium over self-hosting on raw instances. For nearly all teams that is the correct trade — the cost of a self-managed Redis cluster is not the instances, it is the 3 a.m. failover you have to debug yourself.
10.5The comparison table#
| Product | Type | Threading | Data model | Persistence | HA / clustering | Distinctive feature | Best for |
|---|---|---|---|---|---|---|---|
| Memcached | Remote cache | Multi-threaded | Key → blob | ❌ None | Client-side sharding only | Slab allocator; shared-nothing servers | Simple, huge, uniform-value caches |
| Redis | Remote data-structure store | Single-threaded execution | Strings, hashes, sets, sorted sets, streams, HLL, geo | RDB + AOF | Sentinel / Cluster | Atomicity for free; rich structures | The default; anything beyond get/set |
| Valkey | Redis fork | Single + threaded I/O | Same as Redis | Same | Same | BSD-licensed continuation (LF-backed) | Redis needs with open-source licensing |
| Dragonfly / KeyDB | Redis-compatible | Thread-per-core | Redis-compatible | Yes | Yes | Vertical scale without clustering | Single-node throughput ceilings |
| Hazelcast / Ignite | In-memory data grid | Multi-threaded | Maps, queues, SQL | Yes | Built-in partitioning | Embedded in JVM + compute-near-data | JVM shops needing grid semantics |
| Caffeine | In-process (L1) | Lock-free-ish | Objects (no serialization) | ❌ | n/a (per-process) | W-TinyLFU near-optimal hit ratio | JVM local cache; nanosecond hits |
| Ehcache | In-process + tiered | — | Objects | Disk tier | Optional | Heap → off-heap → disk tiering | Large local caches without GC pain |
| Varnish | HTTP reverse proxy | Multi-threaded | HTTP responses | Disk optional | — | VCL programmability + coalescing | High-volume HTTP caching with rules |
| NGINX proxy_cache | HTTP reverse proxy | Event-driven | HTTP responses | Disk | — | Already in your LB | Cheap HTTP caching, no new component |
| ElastiCache | Managed | Per engine | Redis/Valkey or Memcached | Optional | Multi-AZ failover | Zero ops | Default managed cache on AWS |
| MemoryDB | Managed durable | Redis-compatible | Redis structures | Multi-AZ txn log | Yes | Durability guarantee | In-memory data as source of truth |
| DAX | Managed, DynamoDB-specific | — | DynamoDB items | ❌ | Multi-AZ | Transparent write-through for DynamoDB | Removing cache-aside code for DynamoDB |
| Upstash / Momento | Serverless | — | Redis-compatible / API | Yes | Managed | Per-request pricing, HTTP API | Edge/serverless runtimes; spiky traffic |
10.6Decision rules#
- Choose Redis (or Valkey) by default. You almost always end up needing one of: atomic counters, a sorted set, a lock, a session store, or persistence — and having them already there is worth more than Memcached's per-box throughput edge. Choose Memcached instead when the workload is provably pure get/set on uniform values, you want maximum use of many cores per box, and you value having fewer features to misuse.
- Choose Valkey over Redis when open-source licensing matters to your organization or your cloud provider has already moved to it. They are wire-compatible; this is a governance decision, not a technical one.
- Choose Dragonfly/KeyDB when you have measured a single-node ceiling and the alternative is adopting Redis Cluster (with its cross-slot multi-key restrictions) — buying vertical headroom is often cheaper than buying distributed complexity.
- Add a local L1 (Caffeine or equivalent) when you have hot keys (§2.8) or your p99 is dominated by network round trips. Give it a short TTL and treat divergence as bounded rather than solved (§2.9).
- Choose an HTTP/reverse-proxy cache when the expensive part is rendering whole responses for anonymous users — it saves framework and template time that an object cache cannot.
- **Choose MemoryDB (or Redis with AOF
appendfsync always+ replication) when losing the data is unacceptable. Choose ElastiCache when the data is reconstructible from a database** — which is the definition of a cache, and the far cheaper case. - Choose a serverless cache (Upstash/Momento) when your traffic is spiky enough that a provisioned cluster is mostly idle, or when your compute runs in an environment that cannot hold persistent connections.
10.7Anti-recommendations#
- Using Redis as your primary database because it is fast. It can be (with AOF and replication), but you are choosing a datastore whose working set must fit in RAM, whose durability guarantees are configurable in ways that quietly default to "lossy," and whose failover can lose acknowledged writes. If it is the source of truth, choose MemoryDB or an actual database and cache in front of it.
- **
noevictionon a cache instance.** Covered in §4.6 — writes start failing while you have gigabytes of stale keys sitting there. - One giant shared Redis for everything. Sessions, cache, queues, locks, and rate limits in one instance means one hot key (§2.8) or one blocking command takes down five unrelated subsystems at once, and eviction throws away sessions to make room for cached HTML. Separate by workload.
- Reaching for a service mesh or an "in-memory data grid" when you needed a hash map. If the data is small, per-process, and reconstructible, a local cache in your own process is a hundred times faster and involves no new infrastructure.
- Caching without a TTL "because we invalidate properly." You will miss an invalidation path. TTL is the backstop that turns a permanent correctness bug into a temporary one (§2.9).
- Picking a cache before measuring the miss penalty. If the origin query takes 3 ms, a cache buys you 2 ms and costs you a consistency problem, a new failure mode, and an operational burden. Measure first (§2.6).
11Real-World Engineering Scenarios#
11.1Facebook — Memcached at Massive Scale (the "scaling memcache" paper)#
Facebook runs one of the world's largest cache-aside deployments over Memcached, fronting MySQL.
- Reads: web servers check Memcached first; on miss, read MySQL and populate the cache. Writes: update MySQL, then delete the key from Memcached (classic invalidate-don't-update).
- To fight stampedes on hot keys, they use leases: on a miss, a server gets a lease token to be the one that recomputes; concurrent missers wait or get a slightly-stale value — request coalescing at scale.
- They organize caches into pools (separating high-churn keys from stable keys to avoid one workload evicting another) and use regional replication with an invalidation pipeline so a write in one region invalidates caches in all regions (fighting cross-region staleness). They famously described the "thundering herd" and cross-region consistency problems and their lease/invalidation solutions.
- Takeaway: cache-aside + invalidate-on-write + leases (single-flight) + pooling, operated at trillions of ops/day.
11.2Twitter/X — Timeline Fan-out Cache with Redis#
Home timeline rendering is read-dominated and latency-critical.
- When a user posts, the system fans out the tweet ID into the Redis-cached timelines of each follower (write-time fan-out) — precomputing feeds so reads are pure cache hits.
- Timelines are stored as capped Redis lists/sorted sets (only the most recent N kept — natural eviction).
- Celebrity problem: fanning a tweet from someone with 100M followers into 100M timelines is infeasible, so celebrities are handled with read-time merge (hybrid: pull the celebrity's tweets at read time and merge into the cached timeline). This is the canonical "fan-out on write vs fan-out on read" trade-off.
- Takeaway: precomputed (write-back-ish) caches for the common case, read-time merge for the heavy-tail, all in Redis.
11.3Reddit — Caching Rendered Content & Vote Counts#
- Reddit caches heavily in Memcached/Redis: rendered thing-listings, comment trees, and precomputed sorted listings (hot/top/new) so the DB (originally Postgres + Cassandra) isn't recomputing sorts on every pageview.
- Vote counts and score updates use write-back-style aggregation — votes accumulate and are flushed/recomputed rather than transactionally updating a DB row per vote (which would never scale under viral traffic).
- They've publicly discussed cache stampede incidents where a popular page's cache expiry dogpiled the backend, and mitigations like locking and staggered expiry.
- Takeaway: cache the rendered/aggregated output, not just raw rows, and defend hot pages against stampede.
12Interview Gotchas & Failure Modes#
12.1Classic Questions#
- "Why delete the cache key on write instead of updating it?" — To avoid the concurrent-writer race that can leave a permanently-stale value; delete is safe (worst case = one extra miss).
- "How do you keep cache and DB consistent?" — Be honest: usually you accept eventual consistency (TTL + invalidate-on-write). For strong needs: write-through, versioned keys, or read-from-DB for critical paths. There's no free lunch.
- "How do you handle a cold cache after deploy?" — Cache warming (pre-load hot keys), gradual traffic ramp, and stampede protection so the cold spike doesn't kill the DB.
- "Local cache vs distributed cache?" — Local = fastest but per-node-inconsistent and lost on restart; distributed = shared/consistent, one hop away. Often combine (near cache).
- "Implement an LRU cache." — HashMap + doubly-linked list, O(1) get/put. Be ready to code it.
- "How big should the cache be / what hit ratio?" — Driven by working-set size and the Pareto distribution of access; measure hit ratio and tune. There's no universal number.
12.2Failure Modes & Mitigations#
- Stale data served indefinitely (missed invalidation / read race) → short TTLs bound the blast radius; versioned keys; monitoring.
- Cache down = DB overwhelmed (the cache became load-bearing) → cache HA (replication/failover), circuit breaker to the DB, graceful degradation (serve reduced functionality rather than crash).
- **
noevictionmisconfig** → cache fills and starts rejecting writes → app errors. Pick an eviction policy deliberately. - Hot-key on a single shard → one Redis node melts while others idle (a single key can't be sharded). Mitigate with local caching of that key, key replication, or splitting the value.
- Big-key problem → a single huge value (a 1GB list) blocks Redis's single thread on access and skews memory. Split it.
- Memory fragmentation / eviction thrash → hit ratio collapses; monitor
evicted_keys,keyspace_hits/misses, memory fragmentation ratio. - Cache inconsistency across regions → cross-region invalidation lag; accept eventual consistency or route writes+reads regionally.
13Whiteboard Cheat Sheet#
READ (cache-aside): WRITE options:
┌──────┐ hit ┌───────┐ write-through: app->cache->DB (sync, consistent)
│ App │───────►│ Cache │ write-back: app->cache (ack), async->DB (fast, lossy)
└──┬───┘ └───┬───┘ write-around: app->DB, cache filled on read
│ miss │ populate(+TTL,jitter) cache-aside W: app->DB, then DELETE cache key
▼ ▲
┌──────────────────┴┐
│ Database │ (source of truth)
└───────────────────┘
Eviction: LRU (recency) | LFU (frequency, +aging) | W-TinyLFU (modern, scan-resistant) | TTL/volatile
Killers: Penetration (missing data) -> cache nulls + Bloom filter
Avalanche (mass expiry/cache down) -> TTL jitter + cache HA
Stampede (hot key expiry) -> mutex/single-flight + stale-while-revalidate
Metric: hit ratio = hits/(hits+misses) -> aim high (>90%); jitter every TTLOne-sentence summary for an interviewer:
"Caching trades memory for latency by keeping hot data in RAM; pick cache-aside for general read-heavy loads (invalidate-don't-update on write), write-through when you need read-after-write freshness, and write-back for high-write loss-tolerant counters; bound staleness with jittered TTLs, choose an eviction policy deliberately (LRU/LFU/W-TinyLFU), and explicitly defend the database against penetration, avalanche, and stampede."
End of File 02. Next → File 03: Rate Limiting (token bucket, leaky bucket, sliding window, distributed limiting with Redis).