TOPIC 06

Caching Strategies

The single highest-leverage trick in system design: remember the answer instead of recomputing it. Cheap when it works, fascinating when it fails.

Intermediate 35 min read 11 concepts

At a Glance

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.

App Cache RAM · ~1 ms Database disk · ~50 ms HIT fast MISS slow — fetch from DB, then fill cache backfill

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)

1
Client / browser cache

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.

2
CDN cache

Edge servers worldwide cache your static (and sometimes dynamic) responses — Topic 04's whole job.

3
Web server cache

Reverse proxies like Varnish or Nginx cache whole HTTP responses, shielding the app from rendering the same page twice.

4
Application cache

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.

5
Database cache

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.

StrategyWrite latencyStalenessData-loss riskUse when
Cache-asideNormalUntil TTL/invalidationNoneDefault; read-heavy workloads
Write-throughSlower (2 writes)NoneNoneReads must always be fresh
Write-behindFastestNone (cache is truth)Yes — on crashWrite-heavy, loss-tolerant data
Refresh-aheadNormalMinimal for hot keysNonePredictably 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.

FR

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

RedisMemcached
Data typesStrings, lists, hashes, sets, sorted sets, streams, geo, bitmapsStrings/blobs only
PersistenceOptional (RDB snapshots, AOF log)None — pure cache
Replication / HABuilt-in replicas, Sentinel, ClusterNone built in (client-side sharding)
ThreadingMostly single-threaded event loopMulti-threaded — scales across cores
ExtrasPub/sub, Lua scripting, transactions, TTL per keyDeliberately minimal
Pick it whenYou need structures, durability, or HA (most cases)You want a dead-simple, multi-core LRU for flat objects
Real-world receipts

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.

The two hard things

"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
Archie says

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 "user:123" CRC16 % 16384 = 5798 Master Aslots 0–5460 Master B slots 5461–10922 Master Cslots 10923–16383 Replica A Replica B Replica C

Key → slot → master. Each master has a replica that gets promoted automatically if the master dies.

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:

1
Mutex lock

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.

2
Probabilistic early expiration (PER)

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.

3
Stale-while-revalidate

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.

Real-world receipts

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:

HTTP Cache Headers — The Browser Cache Layer

The earlier Cache-Control primer covered the common cases; here's the full vocabulary:

Header / directiveWhat it means
Cache-Control: max-age=86400Browser caches for 24 hours.
Cache-Control: no-storeNever cache (banking pages, private data).
Cache-Control: no-cacheCan cache, but must revalidate with the server before using.
Cache-Control: publicCDN and browser can both cache.
Cache-Control: privateOnly the browser can cache (not the CDN).
Cache-Control: s-maxage=3600CDN TTL — overrides max-age for shared caches.
Cache-Control: stale-while-revalidate=60Serve stale for up to 60s while revalidating in the background.
ETag / If-None-MatchServer sends a checksum; browser echoes it back; server replies 304 Not Modified if unchanged. Saves bandwidth.
Last-Modified / If-Modified-SinceDate-based equivalent of ETag.
Vary: Accept-EncodingCDN caches separate versions for gzip vs brotli vs raw.

How Twitter Uses Redis

Twitter
The scale

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

MemcachedRedis
Data typesStrings only10+ types (string, list, hash, set, sorted set, stream, geo, HLL…)
PersistenceNone (data lost on restart)RDB snapshots + AOF append-only file
ClusteringBuilt-in client shardingRedis Cluster (server-side) or Sentinel (HA)
Pub/SubNoYes (also Streams for durable pub/sub)
ScriptingNoYes (Lua scripts run atomically)
Multi-threadingYes (multi-core)Single-threaded (+ I/O threads in Redis 6+)
Memory efficiencySlightly better for pure stringsSlightly worse due to metadata
Use it whenPure string KV cache, max simplicity, multi-threaded CPU-boundAlmost 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.

Interview Prep

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?

That's a stampede (thundering herd): one popular key, many concurrent misses. Fix with a recompute lock, serving stale while refreshing, or probabilistic early refresh.
Try It

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.