Contents
Course · 04
How does a cache make things fast, and how does it lie to you?
A cache keeps a copy of data close by, so you skip the slow trip. This page covers where caches live, how they fill and empty, how they go wrong, how HTTP and CDNs cache, and the math behind a hit ratio.
A cache trades freshness for speed: every design choice decides how stale a copy may get and who pays on a miss.
- Cache-aside is the default: read the cache, on a miss read the DB and fill the cache; on a write, update the DB then delete the cache key.
- Expired hot keys cause stampedes. Fix with one-fetch-per-key (locks, leases), early refresh, and random jitter on TTLs.
- HTTP caching runs on
Cache-Control(RFC 9111). CDNs like CloudFront serve from the nearest edge and go to your origin only on a miss. - Hit ratio decides everything: at 90% the DB sees 1 in 10 reads; at 99%, 1 in 100.
maxmemory-samples)—Words you need
Cache basics
01Why cache at all
Reading from memory is much faster than reading from disk or over a network. A database query also costs CPU, locks and connections. If many people read the same thing, it is wasteful to compute it again each time.
A cache keeps the answer. It does two jobs. It makes reads faster for users. It also protects the database from load it could not handle.
Caching works when reads are far more common than writes, and when a little staleness is fine. It fails when every read is unique, or when data must be exact to the millisecond.
A cache is a bet that the same data will be read again soon. Check the bet with the hit ratio.
02Where caches live
| Layer | Who shares it | What it holds | Who controls freshness |
|---|---|---|---|
| Browser | One user | Pages, images, API replies | HTTP headers like max-age |
| CDN edge | All users near that site | Static files, some API replies | HTTP headers + CDN TTL settings |
| Reverse proxy | All users of that proxy | Whole HTTP responses | Proxy config + headers |
| App memory | One server process | Objects, config, hot lookups | Your code (TTL, size) |
| Distributed cache | All app servers | Objects, sessions, counters | Your code (TTL, delete on write) |
| DB buffer pool | The database | Table and index pages | The database itself |
An in-process cache is fastest but each server has its own copy. A distributed cache is one shared copy, one network hop away.
Source: MySQL buffer pool · Azure Cache-Aside (local vs shared) · RFC 9111
03Cache-aside (lazy loading)
Read: try the cache. On a miss, read the DB, put the value in the cache with a TTL, and return it.
Write: update the DB first. Then delete the cache key. The next read fills it again.
def get_user(id):
v = cache.get(f"user:{id}")
if v is not None:
return v # hit
v = db.query("SELECT * FROM users WHERE id=%s", id) # miss
cache.set(f"user:{id}", v, ttl=300)
return v
def update_user(id, fields):
db.update("users", id, fields) # 1. source of truth first
cache.delete(f"user:{id}") # 2. then invalidate
| Good | Bad |
|---|---|
| Only data that is read gets cached. | Every miss costs three trips: cache, DB, cache set. |
| If the cache dies, the app still works (slower). | A fresh, empty cache sends a flood to the DB. |
| Simple, works with any cache store. | Data can be stale until TTL or delete. |
Cache-aside is the safe default: DB first, then delete the key, and always set a TTL as a safety net.
Source: AWS ElastiCache strategies · Azure Cache-Aside
04Read-through, write-through, write-behind, write-around
Read-through. Like cache-aside, but the cache library loads from the DB itself on a miss. The app only talks to the cache.
Write-around. Writes go straight to the DB and skip the cache. The cache fills only when someone reads. Good for data that is written once and rarely read, like logs.
| Pattern | Read path | Write path | Fresh? | Risk | Use for |
|---|---|---|---|---|---|
| Cache-aside | App reads cache, then DB on miss | App writes DB, deletes key | Stale until delete or TTL | Miss penalty, races | Most read-heavy apps |
| Read-through | Cache loads from DB on miss | (paired with one below) | Same as cache-aside | Needs a cache that can load | Libraries, CDNs |
| Write-through | Always in cache | Cache and DB, caller waits | Yes, after the write | Slower writes; caches data nobody reads | Read-after-write needs |
| Write-behind | Always in cache | Cache now, DB later in batches | Cache yes, DB lags | Data loss if cache dies | Counters, likes, metrics |
| Write-around | Cache-aside style | DB only | First read is a miss | Recent writes always miss | Write-once, read-rarely data |
The write pattern decides the freshness and the risk: write-through pays on writes, write-behind risks loss, cache-aside accepts brief staleness.
Source: AWS ElastiCache strategies · Azure Cache-Aside
05Eviction: LRU, LFU, FIFO, TTL
A cache is smaller than the data. When it is full and a new item comes, something must go. The eviction policy picks the victim.
| Policy | Throws out | Good when | Weak when |
|---|---|---|---|
| LRU (least recently used) | The item not used for the longest time | Recent items are likely to be used again (most apps) | One big scan pushes out all the hot items |
| LFU (least frequently used) | The item used the fewest times | Some items stay popular for a long time | Old favourites hang on unless counts decay |
| FIFO (first in, first out) | The oldest item added | Very simple; items have similar value | Throws out hot items just because they are old |
| TTL (time to live) | Items whose time is up | Bounding staleness | Not a size limit on its own |
| Random | Any item | All items accessed about equally | Can hit a hot item |
get A → [A]
get B → [A, B]
get C → [A, B, C]
get A → [B, C, A] # A used again, moves to newest
get D → [C, A, D] evict B: least recently used
get C → [A, D, C]
Real LRU needs a hash map plus a doubly linked list. Both give O(1) get and put: the map finds the node, the list moves it to the front.
maxmemory-samples 5 by default. LFU uses a Morris counter (a tiny probabilistic counter) that saturates near one million hits and decays every minute (lfu-log-factor 10, lfu-decay-time 1). The default policy is noeviction: writes fail when memory is full. Redis docs call allkeys-lru "a good default option". Redis: key evictionFor a pure cache, set maxmemory and use allkeys-lru or allkeys-lfu. Never leave a cache on noeviction.
Source: Redis key eviction · MySQL buffer pool (scan-resistant LRU)
When caches go wrong
06Invalidation, stale data and consistency
A cache holds a copy. When the real data changes, the copy is now wrong. Invalidation is how you remove or fix it. There are three tools.
Why delete instead of update? Two writers can update the cache in the wrong order and leave the old value. A delete is safe to repeat; it is idempotent (doing it twice equals doing it once).
A real race in cache-aside
| Step | Reader R | Writer W | Cache holds |
|---|---|---|---|
| 1 | Cache miss for x | nothing | |
| 2 | Reads DB: x = 1 | nothing | |
| 3 | Writes DB: x = 2 | nothing | |
| 4 | Deletes key x | nothing | |
| 5 | Sets cache x = 1 (late) | x = 1, stale until TTL |
Fixes: a short TTL limits the damage. A delayed second delete ("delete again after a short wait") catches late sets; this is a common practice, not a standard. Leases, below, close the gap properly.
Delete, do not update; always keep a TTL; and if staleness really matters, use leases or versioned values.
Source: Scaling Memcache at Facebook (NSDI 2013) · Azure Cache-Aside (consistency)
07Stampede, penetration, avalanche, hot keys
Cache stampede (thundering herd)
A hot key expires. A thousand requests miss at the same moment. All thousand run the same slow query. The DB falls over.
| Fix | How | Cost |
|---|---|---|
| Lock / lease | First miss takes a lock (for example Redis SET key NX PX 5000); others wait briefly or get the old value. | Waiters add latency; lock needs a timeout. |
| Request coalescing (single flight) | In one process or proxy, merge identical in-flight misses into one fetch. | Per node only, unless done in a shared tier. |
| Probabilistic early expiration | Each reader may refresh a little before expiry, with rising chance near the end. | A few extra refreshes; no coordination. |
| Stale-while-revalidate | Serve the old value, refresh in the background. | Users see slightly old data. |
if now - delta * beta * log(random()) >= expiry: # log(random()) is negative
recompute_and_reset() # chance rises as expiry nears
Cache penetration
Requests ask for keys that do not exist, like user:-1. Every one misses and hits the DB. Attackers can do this on purpose.
Fixes: negative caching (store "not found" with a short TTL) and a Bloom filter of all real IDs. A Bloom filter can say "surely not here" with no false negatives, so you skip the DB.
Cache avalanche
Many keys expire at the same moment, often because they were loaded together with the same TTL. Or the whole cache restarts empty. The DB gets everything at once.
Fixes: add jitter (random extra seconds) to TTLs, warm the cache before traffic, use replicas so one node loss does not empty it, and rate limit the DB path.
Hot keys
One key gets a huge share of traffic, like a celebrity's profile. It lives on one cache node, so that node melts while others are idle.
Fixes (common practice, not one standard): a small in-process cache in front for that key, copies of the key under suffixes (key#1 to key#8) read at random, and read replicas.
Every cache failure mode ends the same way, a flood on the DB. Coalesce misses, cache "not found", jitter TTLs, and spread hot keys.
Source: Memcache at Facebook · AWS Builders' Library: Caching challenges · Vattani et al., VLDB 2015 · Redis Bloom filter
The web edge
08HTTP caching headers
HTTP has caching built in. The server adds headers to the response. Every cache on the path, browser or CDN, reads them and obeys.
| Header / directive | Meaning | Example |
|---|---|---|
Cache-Control: max-age=N | Fresh for N seconds after it was made | max-age=604800 (7 days) |
s-maxage=N | Like max-age, but only for shared caches (CDN, proxy); overrides max-age there | max-age=60, s-maxage=3600 |
no-cache | May store, but must check with the server before each reuse | HTML pages that change often |
no-store | Do not store at all | Bank statements |
private / public | Only the browser may store / shared caches may store too | private for a user's profile |
immutable | Will never change while fresh; skip revalidation | Versioned files like app.3f9a.js |
stale-while-revalidate=N | After expiry, may serve stale for N s while refreshing in the background | max-age=600, stale-while-revalidate=30 |
stale-if-error=N | If the origin errors, may serve stale for N s | stale-if-error=86400 |
ETag + If-None-Match | A version tag; if it still matches, server replies 304 Not Modified with no body | ETag: "33a64df5" |
Last-Modified + If-Modified-Since | Same idea using a date | Static files |
# first visit
GET /app.css → 200 OK
Cache-Control: max-age=600
ETag: "v42"
# 15 minutes later: stale, so the browser asks "is v42 still right?"
GET /app.css
If-None-Match: "v42" → 304 Not Modified (no body, reuse the copy)
max-age is a "best before" date. An ETag is a version number: you ask the shop "is version 42 still current?" and they just nod.freshness_lifetime > current_age. The cache picks the lifetime from the first that applies: s-maxage (shared caches only), then max-age, then Expires minus Date, then a heuristic; a typical heuristic is 10% of the time since Last-Modified. stale-while-revalidate and stale-if-error come from RFC 5861. MDN: no-cache "does NOT mean 'don't cache'"; it means revalidate first. RFC 9111 §4.2 FreshnessVersion your static files and give them a long max-age with immutable; give HTML no-cache plus an ETag.
Source: RFC 9111 HTTP Caching · RFC 5861 · MDN Cache-Control · MDN ETag
09CDNs and how CloudFront works
A CDN (content delivery network) is many cache servers around the world. The edge is the site near the user. The origin is your real server or bucket.
| Idea | What it means |
|---|---|
| Pull CDN | The CDN fetches from the origin on the first miss, then caches. You upload nothing to the CDN. CloudFront works this way. |
| Push CDN | You upload files to the CDN's storage ahead of time. Good for big files you know will be wanted. (General industry term; not a CloudFront mode.) |
| Cache key | What makes two requests "the same object". CloudFront's default: distribution domain + URL path. Adding headers, cookies or query strings splits the cache. |
| Invalidation | Tell the CDN to drop objects before their TTL ends. Costs money past a free tier and takes time to spread. |
| Versioned URLs | Put a hash in the file name (app.3f9a.js). A new deploy is a new URL, so no invalidation is needed. |
| Origin shield | One extra cache tier in front of the origin, so all edges share one copy and the origin sees fewer requests. |
PUT/POST skip regional caches. "The first 1,000 invalidation paths that you submit per month are free", and a /* wildcard counts as one path. Without a cache policy the default TTL is 24 hours. Origin Shield consolidates misses, "resulting in as few as one request going to your origin". As of these docs, checked 2026-10-25. AWS: How CloudFront delivers contentKeep the cache key small, version your file names, and treat invalidation as an emergency tool, not a deploy step.
Source: CloudFront: how it works · CloudFront: cache key · CloudFront: invalidation pricing · CloudFront: expiration · CloudFront: Origin Shield
10Cache hit ratio math
Hit ratio = hits ÷ (hits + misses). The numbers below are an example for practice, not a measurement.
| Input (example) | Value |
|---|---|
| Reads per second | 10,000 |
| Cache read time | 1 ms |
| Database read time | 10 ms |
| Hit ratio | 90% |
DB reads/s = 10,000 × (1 − 0.90) = 1,000
avg latency = 0.90 × 1 ms + 0.10 × (1 + 10) ms
= 0.9 + 1.1 = 2.0 ms # a miss pays the cache check too
at 99% hits: DB reads/s = 100, avg = 0.99 × 1 + 0.01 × 11 = 1.1 ms
Database reads per second at each hit ratio
Example of 10,000 reads per second. Hover a bar for the exact number.
Show the full table
| Hit ratio | DB reads/s | Average latency (ms) |
|---|---|---|
| 0% | 10,000 | 11.0 |
| 50% | 5,000 | 6.0 |
| 90% | 1,000 | 2.0 |
| 99% | 100 | 1.1 |
Look at the jump from 90% to 99%. The hit ratio rose 9 points, but DB load fell 10 times. Near the top, every point matters.
keyspace_hits and keyspace_misses in INFO stats, and the docs give the formula keyspace_hits / (keyspace_hits + keyspace_misses) * 100. If the ratio is low, check evicted_keys (cache too small or wrong policy) and expired_keys (TTL too short). Redis: key eviction, using INFOSize the DB for the miss traffic, and remember the miss traffic jumps the moment the cache is cold.
Source: Redis eviction docs (INFO stats) · math is a worked example on this page
Practice
—When to use what
| Situation | Pick | Why |
|---|---|---|
| Read-heavy data, a few seconds of staleness is fine | Cache-aside + TTL, delete on write | Simple, survives cache loss. |
| Users must see their own write at once | Write-through (or delete + read from primary) | Cache updated in the same write. |
| Very high write rate of counters, likes, views | Write-behind with batching | Absorbs writes; accept small loss risk. |
| Logs or data written once, rarely read | Write-around | Keeps the cache for hot data. |
| General purpose cache in Redis | allkeys-lru | Docs call it a good default. |
| Some items stay popular for days | allkeys-lfu | Keeps long-term favourites. |
| Hot key expiry causes DB spikes | Lock/lease or early refresh | One fetch per key. |
| Bots ask for IDs that do not exist | Negative cache + Bloom filter | Misses for "not found" stop at the cache. |
| Static JS, CSS, images | CDN, hashed file names, max-age=31536000, immutable | No invalidation needed; max edge hits. |
| HTML that changes often | no-cache + ETag, or a short s-maxage with stale-while-revalidate | Fresh, but cheap 304s. |
| Per-user data | Cache-Control: private | Never stored in shared caches. |
—Common mistakes and traps
private on a user pageUser-Agent or session cookies to the CDN keyno-cache to mean "never store"no-store for secrets.—Interview questions
Show answer
Show answer
Show answer
Show answer
Show answer
maxmemory-samples is 5 by default, and LFU uses a Morris counter that decays every minute. Redis docs call allkeys-lru a good default. I'd start with LRU and switch to LFU if hit ratio data shows stable popular items being evicted.Show answer
LinkedHashMap with access order does this.Show answer
Show answer
Show answer
Show answer
key#1 to key#N across shards and read one at random (writes must update or delete all copies); use read replicas. For writes to a hot counter, shard the counter and sum on read. These are common practices rather than one standard.Show answer
expired_keys is high and hit ratio low, the TTL is too short.Show answer
max-age=N: fresh for N seconds in any cache. s-maxage=N: same but only for shared caches like CDNs, and it overrides max-age there, so you can keep a page 1 hour on the CDN but 1 minute in browsers. no-cache: may store, but must revalidate with the server before each use, usually via ETag and a cheap 304. no-store: never store, for secrets. RFC 9111 picks freshness in order s-maxage, max-age, Expires, then a heuristic. Common mistake: using no-cache when you meant no-store.Show answer
ETag, a version tag for the response, like "v42" or a content hash. When the cached copy goes stale, the client asks again with If-None-Match: "v42". If the version still matches, the server replies 304 Not Modified with no body, and the client reuses its copy. That saves bandwidth, not the round trip. Strong ETags mean byte-identical; weak ones (W/"…") mean "same meaning". ETags are defined in RFC 9110. They also help with safe updates via If-Match.Show answer
Show answer
Show answer
app.3f9a.js, set by the build. Serve them with Cache-Control: max-age=31536000, immutable. The HTML that references them gets no-cache or a short TTL, so browsers pick up the new names quickly. A new deploy means new URLs, so nothing needs invalidating, and old pages still find their old files. Invalidation is slower and costs money: CloudFront gives 1,000 free paths per month, then charges per path. Keep invalidation for emergencies like a leaked file.Show answer
evicted_keys rise? The working set may have grown past memory, or a scan job polluted LRU. Expiry: did someone shorten a TTL (expired_keys)? Key changes: a deploy that changed the key format or added a version makes every key new. Traffic mix: a new feature reading unique keys, or a bot hitting random IDs (penetration). Infrastructure: a node restart or resharding emptied part of the cache. For a CDN, check whether a header or cookie was added to the cache key.Show answer
s-maxage and stale-while-revalidate, personal parts loaded separately with private. Protect hot items in a sale with locks and an in-process cache. Measure hit ratio per layer.Show answer
Show answer
—Cheat sheet
| Fact | Value | Source |
|---|---|---|
| Cache-aside miss cost | 3 trips: cache, DB, cache set | AWS ElastiCache strategies |
| Cache-aside write order | Update DB, then delete key | Azure Cache-Aside |
| Redis default eviction policy | noeviction | Redis eviction docs |
| Redis LRU sampling | maxmemory-samples 5 | Redis eviction docs |
| Redis LFU defaults | lfu-log-factor 10, lfu-decay-time 1 (minute) | Redis eviction docs |
| Hit ratio from Redis | hits ÷ (hits + misses), INFO stats | Redis eviction docs |
| InnoDB buffer pool | Up to 80% of RAM on dedicated servers; old sublist 3/8 | MySQL 8.4 docs |
| Facebook leases | Token once per 10 s per key; peak DB 17K/s → 1.3K/s | NSDI 2013 paper §3.2.1 |
| Bloom filter, 1% false positives | About 9.6 bits per item, 7 hash functions | Redis Bloom docs |
| Freshness order | s-maxage → max-age → Expires → heuristic | RFC 9111 §4.2 |
| Typical heuristic freshness | 10% of time since Last-Modified | RFC 9111 |
stale-while-revalidate, stale-if-error | Defined in RFC 5861 | RFC 5861 |
| CloudFront default cache key | Distribution domain + URL path | CloudFront cache key docs |
| CloudFront default TTL (no cache policy) | 24 hours | CloudFront expiration docs |
| CloudFront free invalidations | 1,000 paths/month; /* = 1 path | CloudFront invalidation pricing |
| Hit ratio rule | DB load = reads × (1 − hit ratio) | Worked example |
—Sources
| What | Where |
|---|---|
| Cache-aside steps, write order, consistency | learn.microsoft.com: Cache-Aside pattern |
| Lazy loading, write-through, TTL trade-offs | docs.aws.amazon.com: ElastiCache caching strategies |
| Negative caching, coalescing, soft/hard TTL, modal behavior | AWS Builders' Library: Caching challenges and strategies |
| Redis eviction policies, LRU sampling, LFU, INFO stats | redis.io: Key eviction |
| Bloom filter guarantees and sizing | redis.io: Bloom filter |
| InnoDB buffer pool | dev.mysql.com: InnoDB buffer pool |
| Leases, stale sets, thundering herds, 17K/s → 1.3K/s | Nishtala et al., Scaling Memcache at Facebook, NSDI 2013 |
| Probabilistic early expiration (XFetch) | Vattani, Chierichetti, Lowenstein, VLDB 2015 |
| HTTP caching, freshness, heuristic | RFC 9111 |
| stale-while-revalidate, stale-if-error | RFC 5861 |
| Cache-Control directives | MDN: Cache-Control |
| ETag and 304 | MDN: ETag |
| CloudFront request flow and regional edge caches | docs.aws.amazon.com: How CloudFront delivers content |
| CloudFront cache key | docs.aws.amazon.com: Understand the cache key |
| CloudFront TTLs and stale directives | docs.aws.amazon.com: Expiration |
| CloudFront invalidation pricing | docs.aws.amazon.com: Pay for file invalidation |
| Origin Shield | docs.aws.amazon.com: Origin Shield |