At a Glance
- Caching stores expensive results so the next identical request is cheap.
- Cache-aside is the most common strategy: check cache → miss → load DB → populate cache.
- Write-through keeps cache and DB in sync at write time; write-behind makes it async.
- LRU eviction is the default; use TTL to auto-expire stale data.
- Cache stampede, penetration, and avalanche are the three main failure modes.
- Redis vs Memcached: Redis wins for most use cases (persistence, richer data types, pub/sub).
Why Caching Works
Most workloads are wildly skewed: a tiny fraction of data gets the vast majority of reads (the 80/20 rule, often more like 95/5). A cache keeps those hot answers in fast storage (usually RAM) so repeated requests skip the slow path — the database query, the API call, the expensive computation.
Recall the latency table from Topic 01: RAM ≈ 100 ns, SSD ≈ 100 µs. A cache hit can be 1,000× faster than the query it replaces — and it spares your database the work entirely.
Hit: answer in ~1 ms. Miss: pay the full database trip, then store the answer for next time.
The Caching Layers (You're Already Using Five)
HTTP headers tell the browser to keep assets locally. Cache-Control: max-age=86400 means "don't even ask me for a day." ETag enables cheap revalidation: the server replies 304 Not Modified instead of resending the body.
Edge servers worldwide cache your static (and sometimes dynamic) responses — Topic 04's whole job.
Reverse proxies like Varnish or Nginx cache whole HTTP responses, shielding the app from rendering the same page twice.
In-memory stores — Redis and Memcached — hold objects, query results, sessions, and computed values between your app and the database. The layer this topic mostly lives in.
The DB's own buffer pool keeps hot pages in RAM, and some databases cache query results. Free, but invalidation is coarse.
Cache Update Strategies
The hard part of caching isn't reading — it's keeping the cache and the database telling the same story. Four patterns:
1. Cache-aside (lazy loading) — the default
def get_user(user_id):
user = cache.get(f"user:{user_id}")
if user is None: # miss
user = db.query("SELECT … WHERE id = %s", user_id)
cache.set(f"user:{user_id}", user, ttl=3600)
return user
The app owns the logic: check cache → on miss, load from DB → populate cache. Only requested data gets cached, and a dead cache just means slow (not broken). Risks: stale data until TTL expires, and three round trips on every miss. On writes, the usual move is to invalidate (delete) the key, not update it.
2. Write-through
Every write goes to the cache, and the cache synchronously writes to the DB before confirming. The cache is always fresh and reads after writes never miss. Cost: every write pays double latency, and you cache data that may never be read.
3. Write-behind (write-back)
Writes land in the cache and are acknowledged immediately; a background process flushes them to the DB later, often batched. Spectacular write throughput. Risk: the cache crashes before flushing and those writes are gone forever. Use for tolerable-loss data: view counters, analytics, "last seen" timestamps.
4. Refresh-ahead
For hot keys, the cache proactively re-fetches the value shortly before its TTL expires, so popular items never go cold. Works great when you can predict what stays popular; wasteful when you can't.
| Strategy | Write latency | Staleness | Data-loss risk | Use when |
|---|---|---|---|---|
| Cache-aside | Normal | Until TTL/invalidation | None | Default; read-heavy workloads |
| Write-through | Slower (2 writes) | None | None | Reads must always be fresh |
| Write-behind | Fastest | None (cache is truth) | Yes — on crash | Write-heavy, loss-tolerant data |
| Refresh-ahead | Normal | Minimal for hot keys | None | Predictably popular items |
Eviction: Who Gets Kicked Out?
RAM is finite. When the cache fills, an eviction policy chooses the victim:
LRU — Least Recently Used
Evict whatever was touched longest ago. Matches real access patterns well; the industry default.
LFU — Least Frequently Used
Evict the least popular item. Protects steady favorites from being flushed by one-off scans.
FIFO
Evict the oldest insert, ignoring usage. Simple, rarely optimal.
Random
Evict anyone. Shockingly competitive in practice and nearly free to implement (Redis approximates LRU by sampling randomly).
Redis vs Memcached
| Redis | Memcached | |
|---|---|---|
| Data types | Strings, lists, hashes, sets, sorted sets, streams, geo, bitmaps | Strings/blobs only |
| Persistence | Optional (RDB snapshots, AOF log) | None — pure cache |
| Replication / HA | Built-in replicas, Sentinel, Cluster | None built in (client-side sharding) |
| Threading | Mostly single-threaded event loop | Multi-threaded — scales across cores |
| Extras | Pub/sub, Lua scripting, transactions, TTL per key | Deliberately minimal |
| Pick it when | You need structures, durability, or HA (most cases) | You want a dead-simple, multi-core LRU for flat objects |
Facebook runs one of the largest Memcached fleets on Earth (their "Scaling Memcache" paper is a classic). Twitter, GitHub, Stack Overflow, and Discord lean on Redis — Discord famously caches recent messages so channels open instantly.
The Three Classic Cache Disasters
Cache stampede (thundering herd)
A hot key expires. Ten thousand concurrent requests all miss at once and all hammer the database with the same query, possibly knocking it over. Fixes: a mutex/lock so only one request recomputes while others wait or serve stale; or probabilistic early refresh — each request has a small chance of refreshing before expiry, smearing the recompute over time.
Cache penetration
Requests for keys that don't exist anywhere (random IDs, often malicious) bypass the cache every time and hit the DB. Fixes: cache the nulls (set("user:999", NOT_FOUND, ttl=60)), or keep a Bloom filter of all valid keys and reject impossible IDs before they touch storage.
Cache avalanche
Thousands of keys share the same TTL (e.g., all cached at deploy time) and expire in the same second — or the cache node itself reboots empty. The DB takes the entire load at once. Fixes: add jitter to TTLs (ttl = 3600 + random(0, 300)), warm caches before traffic, and run replicated cache clusters.
"There are only two hard things in computer science: cache invalidation and naming things." It's a joke because it's true — every strategy above is a different answer to "when do we admit our copy is wrong?"
Cache-Control, decoded
# Static asset with hashed filename — cache forever
Cache-Control: public, max-age=31536000, immutable
# API response — cache 60s, then revalidate with ETag
Cache-Control: private, max-age=60
ETag: "a1b9c3"
# Sensitive page — never store
Cache-Control: no-store
Interview cheat code: almost every "make it faster / cheaper" follow-up has a caching answer. Just always mention the trade-off in the same breath — "we cache profiles for 5 minutes, accepting up to 5 minutes of staleness."
Redis Data Structures — Supercharged Caching
Redis isn't just a key-value store — it's a toolbox of purpose-built data structures, each unlocking a different system design pattern:
String
Basic cache value, counters (INCR), rate limiters. SET user:123 '{"name":"Alice"}' EX 3600
List
Recent activity feeds, task queues, chat messages. LPUSH notifications:user:1 "new message" then LTRIM notifications:user:1 0 99 keeps only the last 100.
Hash
Object storage without JSON serialize/deserialize for partial updates. HSET user:123 name "Alice" age 30 → update just one field: HSET user:123 age 31.
Set
Unique members, tag clouds, "users who liked this post." SADD post:456:likes user:789 · SCARD post:456:likes for the count.
Sorted Set
Leaderboards (score=rank), rate limiting (score=timestamp), geo. ZADD leaderboard 9800 user:1 · ZREVRANGE leaderboard 0 9 → top 10.
HyperLogLog
Approximate unique counts in 12KB regardless of dataset size. Count unique visitors per day with ~0.81% error.
Pub/Sub
Message fanout: notification delivery, live score updates. Not for reliability — no persistence; offline subscribers miss messages.
Streams
Kafka-lite: persistent pub/sub with consumer groups and message acknowledgment. Streams are the durable version of Pub/Sub.
Redis Cluster — How Data Shards Across Nodes
One Redis instance maxes out a single core and a single machine's RAM. Redis Cluster shards the keyspace across nodes using 16,384 hash slots: CRC16(key) % 16384 = slot number. Each master node owns a range of slots.
Key → slot → master. Each master has a replica that gets promoted automatically if the master dies.
- Replicas: each master has 1+ replica for HA. If a master dies, its replica is promoted automatically.
- Resharding: slots can be moved live between nodes (
MIGRATEcommand). Zero downtime. - Multi-key operations:
MGET/MSETonly work when all keys live on the same slot. Fix with hash tags:{user:123}:nameand{user:123}:agehash on justuser:123, so they land on the same slot. - Typical setup: 6 nodes (3 masters + 3 replicas) for HA. A cluster like this can handle millions of ops/sec.
Cache Stampede — The Thundering Herd Problem, In Depth
The scenario: a popular item's TTL expires. 10,000 concurrent requests all miss simultaneously → all 10,000 hit the database with the same query → DB overwhelmed → cascade failure. Three production-grade fixes:
The first request acquires a Redis lock (SETNX), loads from the DB, stores the result, releases the lock. Everyone else waits. Downside: 9,999 threads sleeping and retrying while one does the work.
As the TTL approaches, requests start randomly refreshing early: if random() < decay_function(TTL_remaining): refresh(). Refreshes are smeared over time instead of all landing at expiry.
Serve the stale value immediately, refresh in the background. Redis has no built-in support, but you can implement it with two TTLs: a "soft" expiry (serve, but trigger refresh) and a "hard" expiry (never serve).
Redis-based mutex in Python
import redis, json, time
r = redis.Redis()
def get_with_lock(key, loader_fn, ttl=3600):
val = r.get(key)
if val: return json.loads(val)
lock_key = f"{key}:lock"
lock = r.set(lock_key, "1", nx=True, ex=10) # 10s lock
if lock:
data = loader_fn()
r.setex(key, ttl, json.dumps(data))
r.delete(lock_key)
return data
else:
time.sleep(0.1)
return get_with_lock(key, loader_fn, ttl) # retry
Cache Penetration — Attacking the DB with Nonexistent Keys
The scenario: a malicious or buggy client queries keys that never exist (user_id: 9999999999). The cache always misses — there's nothing to cache — so every request hammers the DB with useless queries.
Solution 1: cache null values
SET user:9999999999 "null" EX 60. For the next 60 seconds, the cache answers "doesn't exist" itself. Simple and effective against repeated lookups of the same bad key.
Solution 2: Bloom filter
A fast probabilistic structure that answers "definitely not in set" or "probably in set." If Bloom says NO, return 404 immediately — don't even check the cache or DB. Zero false negatives, ~1% false positives. Redis has a BloomFilter module.
Cloudflare uses Bloom filters to avoid looking up non-existent DNS records. Twitter uses them for user lookups — most "does this user exist?" queries never touch storage.
Cache Avalanche — Mass Expiry, In Depth
The scenario: you bulk-loaded your cache with TTL=3600. Every entry expires at exactly t=0 + 3600s. Traffic shifts entirely to the DB, the DB overloads, the cache can't refill fast enough, and the system collapses. Four defenses:
- TTL jitter: instead of
EX 3600, useEX random(3000, 3600). Keys expire scattered over 10 minutes, not all at once. - Staggered cache warmup: don't load every key at startup — warm them gradually or on-demand.
- Circuit breaker at the DB layer: if DB response time exceeds a threshold, stop accepting cache-miss queries and return an error or stale data. Caps the damage even if an avalanche starts.
- Redis persistence (RDB/AOF): on restart, Redis reloads from disk — the cache is not cold after a reboot.
HTTP Cache Headers — The Browser Cache Layer
The earlier Cache-Control primer covered the common cases; here's the full vocabulary:
| Header / directive | What it means |
|---|---|
Cache-Control: max-age=86400 | Browser caches for 24 hours. |
Cache-Control: no-store | Never cache (banking pages, private data). |
Cache-Control: no-cache | Can cache, but must revalidate with the server before using. |
Cache-Control: public | CDN and browser can both cache. |
Cache-Control: private | Only the browser can cache (not the CDN). |
Cache-Control: s-maxage=3600 | CDN TTL — overrides max-age for shared caches. |
Cache-Control: stale-while-revalidate=60 | Serve stale for up to 60s while revalidating in the background. |
ETag / If-None-Match | Server sends a checksum; browser echoes it back; server replies 304 Not Modified if unchanged. Saves bandwidth. |
Last-Modified / If-Modified-Since | Date-based equivalent of ETag. |
Vary: Accept-Encoding | CDN caches separate versions for gzip vs brotli vs raw. |
How Twitter Uses Redis
- Home timeline: a sorted set per user, score = tweet timestamp.
ZADD timeline:user:1 timestamp tweet_id. - Who-to-follow: intersection of mutual follow sets to find suggestions (
SINTERSTORE). - Rate limiting: sliding window rate limiter on a sorted set (score = timestamp, member = request_id).
- Trending topics:
ZINCRBYon each hashtag mention,ZREVRANGEfor the top 10.
At peak, Twitter's Redis fleet handles ~400,000 requests/sec. With each Redis instance doing roughly 100k ops/sec, that's on the order of 4,000 Redis instances (rough estimate).
Memcached vs Redis — The Full Scorecard
| Memcached | Redis | |
|---|---|---|
| Data types | Strings only | 10+ types (string, list, hash, set, sorted set, stream, geo, HLL…) |
| Persistence | None (data lost on restart) | RDB snapshots + AOF append-only file |
| Clustering | Built-in client sharding | Redis Cluster (server-side) or Sentinel (HA) |
| Pub/Sub | No | Yes (also Streams for durable pub/sub) |
| Scripting | No | Yes (Lua scripts run atomically) |
| Multi-threading | Yes (multi-core) | Single-threaded (+ I/O threads in Redis 6+) |
| Memory efficiency | Slightly better for pure strings | Slightly worse due to metadata |
| Use it when | Pure string KV cache, max simplicity, multi-threaded CPU-bound | Almost everything else |
Interview Q&A
Three caching questions interviewers actually ask — click to reveal strong answers.
Write-through ensures cache and DB are always in sync — the cache is never stale. Choose it when: (1) Data is read very shortly after being written (write-through avoids a cache miss on the first read). (2) You can afford slightly higher write latency (the write must hit both cache and DB before ACKing). (3) You have a small, well-known set of hot keys that you always want cached. Cache-aside is better when write rate is high (avoid double writes) or when you don't know which data will be hot.
Redis sorted set is purpose-built for this. ZADD leaderboard score user_id on every score update. ZREVRANK leaderboard user_id for a player's rank — O(log N). ZREVRANGE leaderboard 0 9 for the top 10. ZREVRANGEBYSCORE leaderboard +inf score-1 LIMIT 0 10 for players around a given score. For 10M players, a single Redis instance handles this — sorted sets handle millions of members. Shard by game_id if needed. Sorted sets are backed by a skip list: O(log N) for insert and rank lookup.
Cache invalidation means correctly removing or updating cached data when the source of truth (the DB) changes. "There are only two hard things in CS: cache invalidation and naming things." Why hard: (1) Multiple caches (browser → CDN → app cache) may all hold copies — you must invalidate all of them. (2) Invalidation and the DB update can be non-atomic — there's a tiny window where the DB is updated but the cache still has the old value (or vice versa). (3) Dependent caches: if you cache a user profile, you need to invalidate the user's timeline cache too. Solutions: event-driven invalidation (DB change event → invalidate cache keys), short TTLs (accept some staleness), or design around immutability — cache posts by ID and never mutate, append a new version instead.
Anti-Patterns to Avoid
TTLs that ignore data volatility
Caching the connection pool is correct — but caching query results for too long causes stale data. Always match the TTL to how fast the underlying data changes.
Using the cache as a primary store
Redis has persistence, but it's not your database. Treat it as a best-effort cache — if Redis dies and restarts empty, your system should rebuild the cache from the DB: cold, but functional.
Ignoring cache warming
Deploy a fresh Redis instance and all traffic goes to the DB until the cache fills. For high-traffic systems that's a 10-minute "cold start" outage. Always have a cache warming strategy for deployments.
Caching Interview Questions
Key interview questions on caching strategies, implementation, and optimization.
Core Conceptual Questions
Caching is the technique of storing frequently accessed data in a faster storage layer (e.g., in-memory) to reduce data retrieval time. It helps minimize latency, reduce backend/database load, and improve system scalability and user experience. Caching is critical in high-traffic systems where performance and responsiveness are essential.
Client-side cache: Browser cache, service workers — used for static assets and localStorage. Server-side cache: In-memory stores like Redis or Memcached — used for API responses or session storage. CDN cache: Distributed edge servers cache static content like images, JS, and CSS. Database cache: Materialized views or query result caching — reduces repeated complex DB queries.
Write-through: Data is written to cache and database simultaneously. Ensures strong consistency but results in slower write performance because every write must complete in both layers before returning. Write-back (write-behind): Data is written to cache first; the database is updated later asynchronously. This gives faster writes but carries the risk of data loss if the cache fails before the write propagates to the DB.
Lazy loading (cache-aside) loads data into the cache only when it is needed. On a cache miss, the application fetches data from the database, populates the cache, and then returns the data. It is best used when not all data is frequently accessed or when cache storage is limited. This strategy gives fine-grained control over what is cached, but requires careful cache invalidation management to avoid serving stale data.
Scenario-Based Questions
Cache frequently viewed product data in Redis with a TTL so the cache auto-expires. Use lazy loading so that only requested products are cached, keeping memory usage efficient. Serve static assets such as product images via a CDN cache. On product updates, explicitly invalidate or refresh the cache entry to prevent stale data from being served. This combination reduces database hits dramatically and improves page load speed.
LRU (Least Recently Used) is the default choice when recently accessed items are more likely to be used again — it evicts the item that has not been accessed for the longest time. LFU (Least Frequently Used) is preferable when certain items are accessed much more often than others, because it keeps the most popular items in cache regardless of recency. The eviction strategy should always be chosen to match your actual access patterns in order to minimize cache misses.
Use write-through caching to maintain strong consistency by writing to cache and DB simultaneously. When data changes, invalidate the cache entry manually or via message queues so the next read repopulates from the DB. Set appropriate TTLs to auto-refresh stale data as a fallback. For event-driven architectures, use Change Data Capture (CDC) mechanisms so that DB changes automatically trigger cache updates.
Stale data: Cached values may be outdated if not invalidated properly, causing users to see incorrect information. Cache stampede (thundering herd): Multiple requests for the same uncached data can simultaneously hit the backend, overwhelming it. Overuse of memory: A poor eviction strategy leads to inefficient memory usage and premature eviction of useful data. Increased complexity: Cache invalidation and consistency handling add significant engineering complexity and are a common source of subtle bugs.
Practical Implementation
Use Redis as a key-value store (e.g., product:123 → serialized product data). On a cache miss, fetch the data from the database, store it in Redis with a TTL, and return it to the caller. On a cache hit, return directly from Redis without touching the database. Popular libraries include StackExchange.Redis (C#), redis-py (Python), and ioredis (Node.js). Structure keys consistently and version them (e.g., v2:product:123) so you can bulk-invalidate when data models change.
Use a lock or mutex during a cache miss so only one process or thread populates the cache while others wait (or return a slightly stale value). Apply stale-while-revalidate strategies: serve the old cached value while refreshing it in the background, so no request ever hits the backend cold. Use randomized TTL jitter (e.g., base TTL ± random seconds) to spread out cache expiry across many keys and avoid synchronized mass-expiration events.
Redis Cluster provides high availability, automatic partitioning, and replication across nodes. Memcached is a lightweight, simple in-memory key-value store suited to straightforward caching without data persistence. Hazelcast and Apache Ignite are in-JVM distributed caches suitable for enterprise Java applications. Use consistent hashing to distribute cache keys evenly across nodes, and configure replication to prevent data loss when a cache node goes down.
Further Reading
Quick Check
A celebrity's profile key expires and 50,000 simultaneous requests slam the database with the same query. What just happened?
Interactive: Cache Hit / Miss Visualizer
Click any URL button to simulate a request. The first request always misses (hits the DB at 85ms). Repeat the same URL for a cache hit at 2ms. Fill up the 6-slot cache to see LRU eviction kick in — the least recently used entry is evicted when you add a 7th key.