Lesson 4 finished building the entry layer. From here on we move to what sits behind it, and the first thing we run into is the layer with the best benefit-to-effort ratio in the whole series: caching. This lesson's lab measures 26.5x throughput and a 20x drop in p99 just from turning on caching for one hot key.
But the part actually worth learning isn't that number β it's too easy to predict. Three things are more surprising: dropping the hit ratio from 99.45% to 95.47% (just four percentage points) makes p99 jump from 4.46 ms to 42.11 ms while p50 barely moves at all; single-flight β the classic defense against thundering herd β makes p50 and p99 slightly worse, not better; and jitter on the TTL doesn't cut a single database query, yet still raises throughput by 31%.
5.1 Why caching wins, and by how much
Lesson 1 built the latency order-of-magnitude table. What caching exploits is exactly the gap between two rows in that table: reading from RAM costs hundreds of nanoseconds, while a database query over the network costs milliseconds β roughly a 10,000x gap. Caching doesn't make the database faster; it makes most requests never have to ask it at all.
The average latency of a layer with a cache:
$$T = h \cdot T_{cache} + (1-h) \cdot T_{db}$$
where $h$ is the hit ratio. Plugging in the lab's numbers ($T_{cache} \approx 1.4$ ms, $T_{db} \approx 43$ ms), $h = 0.9$ gives $T \approx 5.6$ ms, and $h = 0.99$ gives $T \approx 1.8$ ms. But this formula hides the part that matters more. The number of database queries the system generates is:
$$Q = (1-h) \cdot N$$
Meaning database load depends on $(1-h)$ β the part that doesn't hit. Going from $h = 90\%$ to $h = 99\%$ sounds like "a 9-point improvement", but $(1-h)$ goes from $0.1$ down to $0.01$: database load drops 10x. One more 9, up to $99.9\%$, and it drops another 10x. Every 9 you add to the hit ratio divides database load by 10.
This isn't paper reasoning. I swept the working-set size (the number of distinct keys traffic touches) and measured the real hit ratio and real database query count:
| Distinct keys | Measured hit ratio | DB queries / request | Throughput | p50 | p99 |
|---|---|---|---|---|---|
| 100 | 99.92% | 0.0008 | 17,711 | 1.50 ms | 4.44 ms |
| 1,000 | 99.45% | 0.0055 | 17,457 | 1.54 ms | 4.46 ms |
| 5,000 | 95.47% | 0.0453 | 11,046 | 1.48 ms | 42.11 ms |
| 20,000 | 23.80% | 0.7618 | 934 | 41.66 ms | 86.08 ms |
| 60,000 | 7.47% | 0.9250 | 737 | 42.73 ms | 88.13 ms |
| 200,000 | 2.40% | 0.9757 | 704 | 43.28 ms | 87.23 ms |
The third column matches the formula digit for digit: $1 - 0.9992 = 0.0008$, $1 - 0.9547 = 0.0453$, $1 - 0.2380 = 0.7620$. The formula $Q = (1-h) \cdot N$ isn't an approximation β it's the definition of hit ratio written a different way.
But the surprising row is the third one. Going from 99.45% to 95.47% is only four percentage points, and p50 barely even moves (1.54 β 1.48 ms). Meanwhile p99 jumps from 4.46 ms to 42.11 ms β a 9.4x jump. Exactly Lesson 1's lesson: the average hides the tail, and the tail is what the user feels.
In the lab, those two numbers live at
/stats and get combined across all three replicas by
the tools/cache-stats.sh script β since each replica counts on its own, reading just one
replica only gives you a third of the truth.
5.2 Cache patterns: who writes where, in what order
Every cache pattern answers two questions: whose job is it to load the cache on a miss, and on a write, does the cache or the database get written first. That's the entire difference between the patterns.
| Pattern | Who loads the cache | Data-loss risk | Use when |
|---|---|---|---|
| Cache-aside | The app, on a miss | No | The default. Read-heavy traffic, a short window of stale data is acceptable |
| Read-through | The cache layer itself | No | App code should know nothing about the cache; in exchange the cache becomes a hard dependency |
| Write-through | Written to both, synchronously | No | A read right after a write must always see the new value; accept slower writes |
| Write-behind | Cache written first, flushed later | Yes | Counters, stats, logs β losing a little data is fine |
| Refresh-ahead | In the background, refreshed before it expires | No | The hot key is known in advance; in exchange it also refreshes keys nobody uses anymore |
Cache-aside in the lab, exactly as the code that's actually running:
const cacheKey = `demo:${key}`;
let hit = null;
try {
hit = await redis.cmd('GET', cacheKey);
} catch {
// IMPORTANT: if Redis is down, the app must still serve the request. The cache is
// a SPEED-UP, not a hard dependency. Catching the error here is exactly the
// difference between "the system got slower" and "the system fell over" (Lesson 17).
hit = null;
}
if (hit !== null) {
cacheHits++;
return json(res, 200, { ...JSON.parse(hit), from: 'cache' });
}
cacheMisses++;
const fresh = await readFromDb(key);
try {
// SET ... EX: always set a TTL. A key with no TTL lives forever, and by the time
// the underlying data changes nobody remembers where to delete it from.
await redis.cmd('SET', cacheKey, JSON.stringify(fresh), 'EX', String(ttl));
} catch {
// Couldn't write the cache β ignore it, don't fail the request over that.
}
return json(res, 200, fresh);
5.3 Cache invalidation β the hardest part
Loading the cache is easy. The hard question is: when the source data changes, how does the cached copy know to go away? Three ways, from simplest to cleanest:
| Approach | Mechanism | Weakness |
|---|---|---|
| TTL | Set an expiry, it disappears on its own once it's reached | There's always a staleness window as long as the TTL. Choosing a TTL means choosing how stale you can tolerate, not tuning for performance |
| Explicit delete | On a DB write, DEL the matching key |
You have to know every affected key β a single list query can be embedded in dozens of keys. Miss one and stale data lives forever |
| Versioned key | Delete nothing; change the key when the data changes | Old keys keep occupying memory until they expire or get evicted β in exchange, nobody ever reads them again |
A versioned key is usually the cleanest solution, because it turns a synchronization problem (delete at exactly the right time, in exactly the right place) into a naming problem:
// EXPLICIT DELETE: you have to remember every related key. Miss one line and you get
// the bug "I fixed it but the page still shows the old number" β a bug that's very
// hard to reproduce.
async function updateProductOld(id, data) {
await db.update(id, data);
await redis.del(`product:${id}`);
await redis.del(`product:${id}:reviews`);
await redis.del(`category:${data.categoryId}:products`); // easy to forget this line...
await redis.del(`search:featured`); // ...and this one
}
// VERSIONED KEY: delete nothing. Bump the version, and EVERY derived key changes with
// it. Old keys turn into garbage nobody reads, and vanish on their own once their TTL
// expires or they get evicted.
async function updateProductNew(id, data) {
await db.update(id, data);
// INCR is atomic in Redis => safe even when multiple replicas write at once.
await redis.cmd('INCR', `ver:product:${id}`);
}
async function readProduct(id) {
const v = (await redis.cmd('GET', `ver:product:${id}`)) || '0';
return cacheAside(`product:${id}:v${v}`, () => db.read(id));
}
SET-ting the old value it had already read.
Result: the cache holds a stale value, and no TTL can save you because the key was just written.This is the deeper reason to use a versioned key: with versioning, the stale value A writes lands in a different key (
...:v3) than the one everyone reads afterward
(...:v4). The race still happens, but it no longer causes damage β this is the best way to
handle a race condition: make it harmless instead of trying to prevent it.
Synchronized TTLs, and what jitter does about it
If many keys get loaded at nearly the same time β this happens on every deploy, every time the cache gets flushed, every time you scale up a replica β and they all share the same TTL, they'll expire in one batch. The fix is to add a random amount to the TTL:
// TTL 5s with jitter 0.6 => every key gets a random TTL somewhere in [2s, 8s].
// The expected value is still 5s, but the expiry moments get SPREAD OUT instead of
// piling up at one point.
function ttlWithJitter(baseSec, ratio) {
if (!(ratio > 0)) return baseSec;
const delta = baseSec * ratio * (Math.random() * 2 - 1);
return Math.max(1, Math.round(baseSec + delta));
}
Real measurement: 300 keys, base TTL 5 seconds, 32 connections, 30 seconds, database pool 2 connections/replica, single-flight on in both configurations, three repeats each:
| Metric | Fixed 5s TTL | 60% jitter TTL |
|---|---|---|
| Throughput | 10,393 / 10,182 / 11,287 | 13,550 / 13,387 / 15,216 |
| p50 | 1.15 / 1.09 / 1.03 ms | 0.89 / 0.82 / 0.56 ms |
| p99 | 72.25 / 77.17 / 69.13 ms | 59.61 / 65.80 / 66.69 ms |
| Max latency | 322.7 / 415.3 / 424.9 ms | 270.1 / 276.6 / 317.0 ms |
| Average DB queue wait | 98.6 / 98.0 / 103.1 ms | 56.9 / 59.3 / 61.6 ms |
| DB queries / request | 0.0105 / 0.0109 / 0.0095 | 0.0102 / 0.0104 / 0.0094 |
Throughput went up 31% and the average database queue wait nearly halved. But look at the last row: database queries per request stayed almost exactly the same (0.0105 vs. 0.0102). Jitter doesn't reduce the amount of work at all β it just spreads that work out over time. The queue got shorter not because there was less work, but because the work stopped arriving all at once.
The cause wasn't that jitter is useless β it was that the experiment was designed wrong: with 2,000 keys and a database pool of only 2 connections, the system was bottlenecked at the database for the entire measurement (hit ratio topped out at 19%). It never reached a state of "idle between expiry batches" β and that idle state is exactly what jitter improves. Dropping to 300 keys so the cache could fill in under a second, and raising the TTL to 5 seconds so the system was genuinely idle between batches, made the effect show up immediately and repeat reliably across all three runs.
The lesson: a measurement that "shows no difference" usually says more about the experiment than about the thing being measured. Before concluding "this mechanism does nothing", check whether the system is even in the state where that mechanism has anything to act on.
5.4 Thundering herd β and a counter-intuitive measurement
A hot key expires. At that exact moment, $N$ requests are asking for it. All $N$ see a miss, all $N$ call the database. The database receives $N$ identical queries at once for exactly one piece of data β that's thundering herd (also called cache stampede).
The worst case is a self-closing loop: the herd overloads the database, the overload slows down requests, the slowdown means nobody gets around to writing the cache back, the empty cache means the next batch of requests becomes another herd. The system can't recover on its own even though the original trigger was just one key expiring.
The first measurement showed nothing at all
I set up an experiment: one hot key, TTL 1 second, 100 connections, 15 seconds. Without single-flight there were 1,600 database queries; turning it on brought that down to 54 β 30x fewer. But latency didn't change at all: the p50, p95, and p99 of both configurations matched within run-to-run noise.
The reason: the lab's "database" at that point had a pool of 10 connections per replica, i.e. 30 concurrent queries, each taking 40 ms β a capacity of roughly 750 queries/second. The herd generated about 89 queries per expiry. It never even touched that limit. The herd was real, measurable in query count, but it never caused any damage.
So I shrank the pool down to 2 connections per replica β a database under real pressure, which is exactly the normal state during an incident. Same experiment, three repeats:
| Metric | No single-flight | With single-flight |
|---|---|---|
| Database queries | 1,100 | 54 |
| Deepest DB queue | 33 / 35 / 33 | 0 |
| Average queue wait | 312.6 / 312.3 / 312.4 ms | 0 ms |
| Max latency | 727.6 / 765.3 / 729.0 ms | 67.7 / 64.0 / 67.4 ms |
| p50 | 4.98 / 5.01 / 5.00 ms | 5.54 / 5.46 / 5.70 ms |
| p99 | 13.18 / 13.05 / 13.29 ms | 14.68 / 13.75 / 13.62 ms |
| Throughput | 15,928 / 15,987 / 15,894 | 15,938 / 16,202 / 16,062 |
One thing worth explaining before you read on: the "No single-flight" database-query count here (1,100) is lower than the 1,600 measured in the pool=10 experiment above, despite being the same herd scenario. That's not because the herd got smaller β the load generator runs in closed-loop mode (covered in Lesson 2): each connection only sends its next request after receiving a response, so when a narrower pool makes every request wait longer, fewer requests complete within that same 15 seconds, which means fewer misses and fewer database queries. The 1,600 and 1,100 come from two experiments with different pools, not one single measurement.
Read this table carefully. Single-flight cuts database queries by 20x, wipes out the queue entirely (depth 33 down to 0, wait 312 ms down to 0), and cuts the max latency from ~730 ms down to ~66 ms β 11x.
But p50 and p99 got slightly worse: p50 from 5.00 up to 5.57 ms, p99 from 13.17 up to 14.02 ms. If I'd only measured p50 and p99 β the exact two metrics most dashboards show β the conclusion would have been "single-flight doesn't help, it's even slightly harmful", and I would have thrown it away.
And why does single-flight make p50 worse? Because it changes the shape of the damage. Without it, 1,100 requests each absorb the full 312 ms queue wait. With it, 1,746 requests "join the club" and wait together on a single load β each one only waits about 40 ms, but the number of requests affected is larger. The total damage shrinks a lot, but it gets spread thin, which is enough to nudge p50.
The practical takeaway: judge an anti-herd mechanism by database queries, queue depth, and max latency. Looking only at p50/p99 can make the correct mechanism look useless. And on a real database, that queue is shared with every other query β so those 312 ms don't just slow down the hot key, they slow down parts of the system that have nothing to do with it. That's damage this lab can't measure.
The single-flight implementation, exactly the code running in the lab:
const inFlightLoads = new Map();
function singleFlight(key, loader) {
const pending = inFlightLoads.get(key);
if (pending) return pending; // join the load that's already in flight
// MUST delete from the map inside `finally`. Otherwise one failure leaves the key
// stuck forever with a rejected promise, and EVERY later request gets that same error.
const promise = loader().finally(() => inFlightLoads.delete(key));
inFlightLoads.set(key, promise);
return promise;
}
// Used on the read path:
const fresh = useSingleFlight
? await singleFlight(cacheKey, load)
: await load();
That exact match isn't a coincidence: it proves that single-flight only coalesces requests within ONE process. The
inFlightLoads map lives in the memory of a single replica, so every replica still ends up
loading once on its own. Getting down to exactly 18 queries requires a shared lock in Redis (SET key val NX EX 5) β but that brings a whole new layer of problems: if the process holding the lock dies before writing
the cache, every other request waits out the lock's expiry, and you have to choose between "wait for the
lock" and "return stale data".With 3 replicas, the gap between 54 and 18 isn't worth the added complexity. With 300 replicas, the answer is different. This is the kind of decision that depends on scale and has no universally correct advice β but knowing where you sit on that axis is not optional.
Three anti-herd mechanisms, and when to use each:
| Mechanism | What it defends against | The cost |
|---|---|---|
| Single-flight | Many requests missing on ONE key at once | Only coalesces within one process; p50 nudges up because many requests wait on one load |
| TTL jitter | MANY keys expiring at the same time | Doesn't reduce total work, only spreads it out; a given key's real TTL is no longer predictable |
| Serve stale while refreshing | Both β and nobody has to wait | Users get data older than the TTL; you have to keep an extra "expired but still usable" copy |
| Warm the cache before opening traffic | An empty cache right after a deploy or a flush | You have to know which keys are hot in advance; adds a step to the deploy process |
The first two mechanisms defend against different problems and don't substitute for each other β that's why the lab turns both on at once in the jitter measurement in section 5.3.
This is the same amplification effect seen in Lesson 4 section 4.5, viewed from the cache's side. Lesson 17 goes deeper; what to remember for now: retry at exactly one tier, with increasing backoff and random jitter.
5.5 Eviction and cache key design
A cache is always smaller than the source data, so something eventually has to go. Redis decides what to
drop using maxmemory-policy, and this choice changes system behavior far more than its size
suggests:
| Policy | Which key gets dropped | Good fit when |
|---|---|---|
allkeys-lru |
The key unused for longest | A good default for a pure cache. Assumption: recently used means likely to be used again |
allkeys-lfu |
The least frequently used key | There's a stable set of hot keys long-term; handles cold scans better than LRU |
volatile-lru |
Only drops keys WITH a TTL | Redis is doubling as both a cache and storage for data that must not be lost |
noeviction |
Drops nothing β writes fail instead | When losing data is unacceptable. Very dangerous for a cache: running out of memory means writes stop, the hit ratio freezes and then drifts down |
The lab's Redis deliberately sets maxmemory 64mb with allkeys-lru so you can
trigger eviction yourself and watch it happen. Once memory runs out, INFO stats tells you how
many keys got dropped:
docker compose --profile cache up -d
# Push a very large key space to blow past 64mb, then see how many keys Redis dropped
docker compose run --rm loadgen loadgen.js \
--url "http://lb:8080/cached?ttl=300" -c 32 -d 20 --key-space 200000
docker compose exec redis redis-cli INFO stats | grep evicted_keys
docker compose exec redis redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human'
# Key count and hit ratio as seen by Redis itself (different from the app-side hit ratio)
docker compose exec redis redis-cli DBSIZE
docker compose exec redis redis-cli INFO stats | grep keyspace
A badly designed key makes the cache useless
The most expensive pitfall in this lesson isn't picking the wrong eviction policy β it's getting the key wrong. If the key contains something that changes on every request, every request creates a new key: the cache writes constantly, never reads anything back, hit ratio sits near 0 β and it still "works", with no error at all.
// ===== BAD: a new key on every request, hit ratio ~ 0 =====
`page:${req.url}` // includes ?utm_source=... => each share creates a new key
`user:${id}:${Date.now()}` // timestamp => never matches
`search:${JSON.stringify(filters)}` // property order varies => key differs for the same filters
`cart:${sessionId}` // one key per session => the cache serves exactly one person
// ===== GOOD: keep only what ACTUALLY decides the result =====
const CACHE_PARAMS = ['page', 'sort', 'category']; // allowlist, not a blocklist
function cacheKeyFor(url) {
const u = new URL(url, 'http://x');
const parts = CACHE_PARAMS
.filter((k) => u.searchParams.has(k))
.sort() // SORT: ?a=1&b=2 and ?b=2&a=1 must map to the same key
.map((k) => `${k}=${u.searchParams.get(k)}`);
return `page:${u.pathname}|${parts.join('&')}`;
}
// cacheKeyFor('/products?utm_source=fb&sort=price&page=2')
// -> 'page:/products|page=2&sort=price' utm_* dropped, order normalized
CACHE_PARAMS above) instead of
listing the ones to strip out. Reason: a blocklist has to be updated every time marketing adds a new
tracking parameter, and when someone forgets, the hit ratio quietly drifts down with no error to catch
it. An allowlist defaults to safe β unknown parameters get ignored.
Two more problems worth knowing by name. Hot key: one key gets hit so hard it skews load
onto a single Redis shard β every other shard sits idle while one is overloaded; the fix is to replicate
that key into several copies (hot:v1#0β¦hot:v1#7) and pick one at random on each
read. Cache pollution: a cold-data scan (a report job, a backup, a bot) pushes all the
hot data out of the cache β LFU tolerates this better than LRU, or you route the scan down a path that
skips the cache entirely.
Reproducing the measurements yourself
cd blog/sysdesign/sysdesign-lab
docker compose --profile cache up -d
# --- Section 5.1: no cache vs. a hot cache ---
docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/uncached?key=hot" -c 32 -d 10 -w 3 --json
./tools/cache-stats.sh
docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=300" -c 32 -d 10 -w 3 --json
./tools/cache-stats.sh
# --- Section 5.4: thundering herd. MUST shrink the DB pool to 2 first, otherwise you
# --- will see exactly the surprise I ran into: 30x more queries with latency unchanged.
# Change DB_MAX_CONCURRENCY to '2' in docker-compose.yml, then:
docker compose --profile cache up -d --force-recreate app1 app2 app3
docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=1" -c 100 -d 15 -w 3 --json
./tools/cache-stats.sh # check dbQueries + queue depth
docker compose exec redis redis-cli flushall && ./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/cached?key=hot&ttl=1&flight=single" -c 100 -d 15 -w 3 --json
./tools/cache-stats.sh # compare: dbQueries, qDepth, and the MAX latency
Summary
Database load is proportional to $(1-h)$, so every 9 you add to the hit ratio divides that load by 10 β and all the value sits in those last few 9's, not in going from 0% to 90%. Cache-aside is the default because it's the only pattern where a dead cache just makes the system slower, not broken. Cache invalidation is the hardest part, and a versioned key is usually the cleanest solution because it makes the write race harmless instead of trying to prevent it.
But the biggest lesson in this one is about how you measure. Three times while writing this, the numbers said the opposite of what I expected, and all three times the cause was the measurement, not the mechanism: jitter "did nothing" because the system was bottlenecked the whole time so there was no batch to smooth out; thundering herd "caused no harm" because the database pool was far wider than the herd; single-flight "made things slower" because p50 and p99 don't contain the 0.39% group that got hurt. In all three, the data was correct β the question was wrong.
Lesson 6 takes this exact idea further out β to the edge layer, right next to the user β where the limit is no longer CPU or memory but the speed of light, and where one cache-key misconfiguration makes origin load jump 251x while not a single client-side metric changes.
π Further reading
- Redis β the EXPIRE command (TTL semantics, and what happens once the TTL fires)
-
Redis β the SET command (the
EX/NXoptions used for cache-aside and a shared single-flight lock) -
Redis β Eviction Policies (what each
maxmemory-policymeans: LRU, LFU, volatile, noeviction) -
Redis β the INFO command (the
evicted_keysandused_memory_humanfields used in section 5.5) - Microsoft Azure Architecture Center β the Cache-Aside pattern
- Wikipedia β Cache stampede (the general form of thundering herd, used in section 5.4)
Download the lab source
The app server running in the lab, containing all three mechanisms measured in this lesson: cache-aside through Redis, single-flight against thundering herd, and jittered TTLs:
Download app.js
Comments