SLIDE 63: What is Caching
Section 6: Caching
A cache is a temporary storage layer that holds frequently accessed data in a faster medium so that future requests for that data can be served without going to the slower original source. In system design, caching is the single most impactful performance optimization available.
What We’ll Cover:
- Why caching works (locality of reference)
- Cache hit vs cache miss
- Where to cache (the four layers)
- Caching strategies (cache-aside, write-through, write-behind)
- Cache eviction policies (LRU, LFU, TTL)
- Cache invalidation
- Redis
- Caching failure modes and mitigations
The Core Idea:
Real-World Impact:
Caching doesn’t just make things faster. It reduces infrastructure cost by reducing load on the most expensive component: the database.
SLIDE 64: Locality of Reference and the 80/20 Rule
Why Caching Works
Caching works because access patterns are not random. In most systems, a small percentage of data receives a disproportionately large percentage of requests. This is known as the 80/20 rule or Pareto distribution.
The 80/20 Rule Applied to Systems:
Instagram:
- 20% of posts receive 80% of views
- A viral post from a celebrity gets millions of views
- A post from a private account gets 50 views
- Caching the top 20% of posts handles 80% of read traffic
YouTube:
- ~10% of videos account for ~90% of views
- "Baby Shark" has 14 billion views
- Most videos have under 1,000 views
- Caching popular videos saves enormous bandwidth
E-commerce (Amazon):
- 20% of products generate 80% of page views
- iPhone listing: millions of views per day
- Obscure industrial part: 3 views per month
- Cache the popular product pages
Types of Locality:
What to Cache:
Good candidates for caching:
- Data that is read frequently and written rarely (user profiles)
- Data that is expensive to compute (feed ranking results)
- Data that doesn't change often (product catalog, configuration)
- Data where slight staleness is acceptable (follower counts)
Bad candidates for caching:
- Data that changes on every read (real-time stock prices with microsecond precision)
- Data that is unique per request (one-time password)
- Data that must always be perfectly consistent (bank balance during transfer)
- Large data that is rarely re-accessed (log entries)
SLIDE 65: Cache Hit vs Cache Miss
Cache Hit and Cache Miss
Every cache lookup has two possible outcomes.
Cache Hit: Data is found in the cache. Return it directly. Fast path.
Cache Miss: Data is not in the cache. Fetch from the original source (database), return it, and optionally store it in the cache for next time.

Cache Hit Rate:
Cache hit rate = cache hits / total requests × 100%
Example:
- 100,000 requests per second
- 80,000 served from cache (hits)
- 20,000 go to database (misses)
- Hit rate: 80%
Impact of hit rate on average latency:
- 70% hit rate: 0.7 × 0.5ms + 0.3 × 50ms = 15.35ms
- 80% hit rate: 0.8 × 0.5ms + 0.2 × 50ms = 10.40ms
- 90% hit rate: 0.9 × 0.5ms + 0.1 × 50ms = 5.45ms
- 95% hit rate: 0.95 × 0.5ms + 0.05 × 50ms = 2.98ms
Every 10% improvement in hit rate roughly halves average latency.
Going from 80% to 90% has more impact than going from 0% to 80%
on per-request latency.
What Determines Hit Rate:
SLIDE 66: Caching Layers
Where to Cache: The Four Layers
Caching can happen at multiple points in the request path. Each layer has different characteristics, and production systems typically use several layers simultaneously.
Which Layer Handles What:
SLIDE 67: Cache-Aside Pattern
Cache-Aside (Lazy Loading)
Cache-aside is the most common caching pattern. The application manages the cache explicitly: it checks the cache first, and on a miss, it fetches from the database and populates the cache.
How It Works:
def get_user_profile(user_id):
# Step 1: Check cache
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached is not None:
return json.loads(cached) # Cache hit, return immediately
# Step 2: Cache miss, query database
user = db.query("SELECT * FROM users WHERE user_id = ?", user_id)
# Step 3: Store in cache for future requests
redis.setex(cache_key, 300, json.dumps(user)) # TTL = 300 seconds (5 min)
return user
Characteristics:
Advantages:
- Only caches data that is actually requested (no wasted memory on unused data)
- Simple to implement
- Cache failure doesn’t break the system (falls back to database)
Disadvantages:
- First request for any data is always slow (cache miss)
- Cache and database can be inconsistent (stale data until TTL expires)
- Cold start problem: after cache restart, all traffic hits the database
SLIDE 68: Write-Through and Write-Behind Patterns
Write-Through Cache
In write-through, every write goes to both the cache and the database. The cache is always up-to-date.
def update_user_profile(user_id, new_data):
# Step 1: Write to database
db.update("UPDATE users SET ... WHERE user_id = ?", user_id, new_data)
# Step 2: Write to cache (same transaction)
cache_key = f"user:{user_id}"
redis.setex(cache_key, 300, json.dumps(new_data))
# Cache is immediately consistent with database
Write-Behind (Write-Back) Cache
In write-behind, writes go to the cache first, and the cache asynchronously flushes to the database later.
def update_user_profile(user_id, new_data):
# Step 1: Write to cache only (fast)
cache_key = f"user:{user_id}"
redis.setex(cache_key, 300, json.dumps(new_data))
# Step 2: Enqueue async write to database
queue.send({"action": "update_user", "user_id": user_id, "data": new_data})
# Database is updated later by a background worker
SLIDE 68B: Caching Strategies: Comparison and Selection Criteria
Strategy Comparison:
When to Use Each:
Cache-Aside (most common):
- Read-heavy workloads (social media feeds, user profiles)
- Data where slight staleness is acceptable
- When you want simplicity
Write-Through:
- Data that must be immediately consistent in cache and database
- Read-after-write scenarios (user updates profile, immediately views it)
- When write volume is moderate
Write-Behind:
- Write-heavy workloads (analytics events, counters)
- When write latency must be minimal
- When you can tolerate potential data loss on cache crash
- When the database cannot keep up with write throughput
In Practice:
Most production systems use cache-aside for the majority of their data, with write-through for critical paths where read-after-write consistency matters (e.g., user just updated their display name and needs to see it immediately).
SLIDE 69: Cache Eviction Policies
Cache Eviction
Caches have limited memory. When the cache is full and new data needs to be stored, an existing entry must be removed. The eviction policy determines which entry to remove.
Why Eviction Policy Matters:
Cache size: 10 GB (can hold ~10 million user profiles at ~1 KB each)
Total users: 2 billion
Only 0.5% of users fit in cache at any time.
The eviction policy determines WHICH 0.5% stays in cache.
A good policy keeps the most frequently accessed data.
A bad policy evicts popular data and keeps rarely accessed data.
Impact:
- Good policy: 85% cache hit rate
- Bad policy: 40% cache hit rate
- Difference: 4.5x more database load with a bad policy
Common Eviction Policies:
SLIDE 70: LRU (Least Recently Used)
LRU: Least Recently Used
LRU is the most widely used eviction policy. It evicts the entry that hasn’t been accessed for the longest time. The intuition: if you haven’t used something recently, you’re unlikely to use it soon.
How It Works:
Implementation: Hash Map + Doubly Linked List
Redis uses an approximated LRU for performance reasons. Instead of tracking the exact access order of every key (memory overhead), Redis samples a subset of keys and evicts the least recently used among the sample. This is close enough to true LRU for production use.
SLIDE 70B: Other Eviction Policies
LFU (Least Frequently Used):
Evicts the entry with the fewest total accesses.
Advantage over LRU:
- A consistently popular item won't be evicted just because
it wasn't accessed in the last few minutes
- Better for workloads with stable hot data
Disadvantage:
- New items start with low frequency and get evicted quickly
(even if they would become popular)
- Must track access count per key (more memory overhead)
Use case: CDN caching where popular content stays popular for weeks
FIFO (First In, First Out):
Evicts the oldest entry, regardless of access pattern.
Advantage: Simplest to implement (just a queue)
Disadvantage: Ignores popularity entirely
Use case: Simple TTL-based caches where age is the only factor
TTL (Time to Live):
Each entry has an expiration timestamp. Entries are removed when expired.
SET user:123 {profile_data} EX 300 # Expires in 300 seconds (5 minutes)
TTL is not strictly an eviction policy. It's typically combined with
another policy (e.g., LRU with TTL). TTL handles staleness, LRU
handles capacity.
Choosing TTL values:
- Session data: 30 minutes
- User profile: 5-15 minutes
- Feed cache: 1-5 minutes
- Configuration: 1-24 hours
- Product catalog: 1 hour
Trade-off: Longer TTL = higher hit rate but more stale data
SLIDE 71: Cache Invalidation
Cache Invalidation
Cache invalidation is the process of removing or updating stale data from the cache when the underlying source data changes. This is famously described as one of the two hard problems in computer science (the other being naming things).
The Problem:
Why It’s Hard:
Option A: Invalidate immediately on every write
- Guarantees freshness
- But: requires knowing every cache key affected by every write
- A single database write might affect 10 different cache keys
(user profile cache, feed cache, search cache, follower list cache)
- Coordinating all invalidations correctly is error-prone
Option B: Use short TTLs
- Simple (no invalidation logic needed)
- But: frequent cache misses reduce hit rate
- But: data is stale for up to TTL duration
Option C: Never invalidate, always serve stale
- Highest hit rate
- But: completely unacceptable for most use cases
SLIDE 71B: Invalidation Strategies
Invalidation Strategies:
1. TTL-Based (Time-Based Expiry):
Set an expiration on every cache entry. Data is automatically
removed after the TTL expires.
redis.setex("user:123", 300, profile_data) # Expires in 5 minutes
Pros: Simple, predictable, no coordination needed
Cons: Data is stale for up to TTL duration
Best for: Data where slight staleness is acceptable (follower count, feed)
2. Event-Based (Active Invalidation):
def update_user_profile(user_id, new_data):
# Update database
db.update("UPDATE users SET ... WHERE user_id = ?", user_id, new_data)
# Invalidate cache (delete the stale entry)
redis.delete(f"user:{user_id}")
# Next read will trigger a cache miss and fetch fresh data
# More aggressive: invalidate AND repopulate
def update_user_profile_v2(user_id, new_data):
db.update("UPDATE users SET ... WHERE user_id = ?", user_id, new_data)
redis.setex(f"user:{user_id}", 300, json.dumps(new_data)) # Write-through
Pros: Data is fresh immediately after write
Cons: Must identify and invalidate all affected cache keys
Risk of bugs (forget to invalidate a key, stale data persists)
Best for: Data where freshness matters (user profile, permissions)
3. Hybrid (TTL + Event-Based):
Use event-based invalidation for critical writes (profile update, permission change)
AND set a TTL as a safety net (if invalidation is missed, TTL catches it)
This is the most common production approach.
Event-based handles the common case (immediate freshness).
TTL handles the edge case (if event-based invalidation fails).
Comparison:
SLIDE 72: Redis
Redis
Redis (Remote Dictionary Server) is an in-memory key-value data store. It is the most widely used caching technology in production systems, but it is more than just a cache. Redis supports multiple data structures and can serve as a cache, session store, message broker, leaderboard engine, and rate limiter.
Key Characteristics:
Why Redis is So Fast:
All data lives in RAM:
- RAM read: ~100 nanoseconds
- SSD read: ~150 microseconds (1,500x slower)
- HDD read: ~10 milliseconds (100,000x slower)
Single-threaded event loop:
- No locking overhead
- No context switching
- Processes commands sequentially at extreme speed
Optimized data structures:
- Hash tables, skip lists, compressed lists
- Purpose-built for fast in-memory operations
Common Production Deployments:
Small (1 app, <10K users):
Single Redis instance, 1-4 GB RAM
Medium (microservices, 100K-10M users):
Redis Sentinel: 1 primary + 2 replicas, automatic failover
16-64 GB RAM per node
Large (Instagram, Twitter scale):
Redis Cluster: 100-400 nodes
Data partitioned across nodes by key hash
Total memory: 1-10 TB across cluster
SLIDE 72B: Redis Data Structures and Use Cases
Redis Data Structures:
Redis is not just GET/SET. It supports multiple data structures, each optimized for specific access patterns.
Worked Examples:
SLIDE 73: Caching Failure Modes
Caching Failure Modes
When caching goes wrong, it doesn’t just fail to help. It can make things worse than having no cache at all. These are the three failure modes every system designer should know.
1. Cache Stampede (Thundering Herd):
2. Cache Penetration:
3. Cache Avalanche:
Scenario:
- Large number of cache entries expire at the same time
- Massive spike in cache misses
- Database suddenly receives all the traffic the cache was absorbing
- Database cannot handle the spike, system degrades or crashes
Cause: Setting the same TTL for all entries loaded at the same time
(e.g., cache warmed up at startup, all entries get TTL=300s,
all expire exactly 300 seconds later)
SLIDE 73B: Mitigations for Each Failure Mode
Mitigations:
Cache Stampede Solutions:
1. Locking (Mutex):
First request acquires a lock, fetches from database, populates cache.
Other requests wait for the lock, then read from cache.
def get_with_lock(key):
value = redis.get(key)
if value: return value
if redis.set(f"lock:{key}", 1, nx=True, ex=5): # Acquire lock
value = db.query(...)
redis.setex(key, 300, value)
redis.delete(f"lock:{key}")
return value
else:
time.sleep(0.05) # Wait for other request to populate
return redis.get(key) # Read from cache
2. Early Refresh:
Refresh cache before TTL expires. If TTL is 300s, refresh at 270s.
Cache never actually expires, so stampede never happens.
Cache Penetration Solutions:
1. Cache null results:
If database returns no result, cache a null/empty value with short TTL.
redis.setex("user:99999999999", 60, "NULL") # Cache "not found" for 60s
2. Bloom filter:
Before checking cache or database, check a bloom filter.
If the bloom filter says "definitely not exists," skip the lookup entirely.
Bloom filters use minimal memory (~1 byte per entry) and have O(1) lookup.
Cache Avalanche Solutions:
1. Randomized TTL:
Instead of TTL=300 for all keys, use TTL=300 + random(0, 60).
Entries expire at different times, spreading the load.
2. Staggered warm-up:
When restarting cache, don't load everything at once.
Load entries gradually over 5-10 minutes.
3. Multi-layer caching:
If application cache misses, fall back to a secondary cache
or CDN cache before hitting the database.
SLIDE 74: Caching at Scale: Real-World Numbers
Caching at Scale
Netflix (200M+ subscribers):
Architecture:
- EVCache (based on Memcached): distributed caching layer
- ~3 trillion cache operations per day
- Cache hit rate: 99%+ for user profiles and content metadata
- 1,000+ cache nodes across multiple AWS regions
Impact:
- Without cache: Would need ~100,000 database servers
- With cache: ~1,000 database servers (99% reduction)
Instagram (2B+ users):
Architecture:
- Redis and Memcached for different use cases
- Feed cache: pre-computed feed stored per user in Redis
- Session cache: user sessions in Memcached
- Cache cluster: 100s of instances
Feed caching strategy:
- Cache key: "feed:{user_id}"
- Value: Sorted set of post IDs
- TTL: 5 minutes
- Hit rate: ~80%
- Feed load from cache: ~50-100ms
- Feed load from database: ~300-500ms
Key Takeaways from Production Caching:
SLIDE 75: Caching: Common Interview Mistakes
Common Interview Mistakes
Mistake 1: "We'll cache everything"
Problem: Caching has costs (memory, invalidation complexity, staleness).
Not everything should be cached.
Better: "I'll cache the user profile and feed data because they're
read-heavy (100:1 read:write ratio) and slight staleness is acceptable.
I won't cache the payment status because it must always be consistent."
Mistake 2: Not mentioning cache invalidation
Problem: Saying "we'll use Redis to cache" without explaining how
stale data is handled shows incomplete thinking.
Better: "We'll use cache-aside with a 5-minute TTL. On profile update,
we'll invalidate the cache key immediately. The TTL acts as a safety
net if the invalidation fails."
Mistake 3: Ignoring cache failure scenarios
Problem: Not considering what happens when the cache goes down.
Better: "If Redis goes down, we fall back to database reads.
Response times will increase from ~1ms to ~50ms, but the system
remains functional. We'll use Redis Sentinel for automatic failover
to minimize cache downtime."
Mistake 4: Using cache for data that needs strong consistency
Problem: Caching bank balances or inventory counts with a 5-minute TTL.
Better: "For inventory counts, we read directly from the database
within a transaction. Serving a stale inventory count could result
in overselling."
Mistake 5: Not specifying TTL values
Problem: "We'll cache it with a reasonable TTL."
Better: "Feed cache: 5-minute TTL. User profile: 15-minute TTL.
Configuration data: 1-hour TTL. Session: 30-minute TTL."
Specific numbers show practical experience.
SLIDE 76: Section Summary and Interview Tips
Section 6 Summary: Caching
Concepts Covered:
Key Numbers to Remember:
- Redis GET/SET latency: ~0.1-0.5ms
- Redis throughput: 100K-500K ops/sec per node
- Target cache hit rate: 80%+ (production systems often achieve 90-99%)
- Typical TTLs: 1-15 minutes for application cache, hours-days for CDN
- Cache vs database speed: 10-100x faster
- Netflix cache hit rate: 99%+
How This Connects to Other Sections:
Section 5 (Scaling): Caching is Stage 5 of the scaling progression
Section 7 (Load Balancing): Load balancer distributes traffic, cache reduces it
Section 8 (CDN): CDN is a specialized cache at the edge layer
Section 13 (Design Exercise): URL shortener uses cache for most-accessed URLs