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%.

ℹ️ The machine every figure in this lesson was measured on
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Containers run native arm64 (Node v22.23.2). Three app replicas, each 1 CPU / 256 MB; nginx 1.27-alpine; Redis 7-alpine; the load generator gets 2 CPUs. The lab's "database" is a 40 ms wait with a cap on concurrent queries β€” this detail matters, see section 5.4. The database pool is 10 connections/replica for sections 5.1–5.3, shrunk to 2 for section 5.4; every change is called out explicitly. Every experiment was repeated at least 3 times.

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.

Every 9 added to the hit ratio = database load divided by 10 Remaining DB load = (1 βˆ’ h) Β· N β€” vertical axis in powers of 10 100% 10% 1% 0.1% h = 0% h = 90% 10% load h = 99% 1% load h = 99.9% Γ·10 9 percentage points of hit ratio = 10x database load. Never say "the cache is at 90%, that's enough". Real measured throughput β€” the cliff sits right below 95% 18k 9k 0 95.47% β†’ 11,046 rps 99.45% β†’ 17,457 rps below 25% β†’ only ~700–930 rps (the 24–95% range wasn't measured) hit ratio β†’ All the value sits in the 95–100% range. The left side is a system already bottlenecked at the database. p99: 42 ms at 95.47% Β· 4.5 ms at 99.45% β€” while p50 stays almost identical.
The left panel is the math, the right panel is the lab's real measurements. Both say the same thing: the region worth investing in is those last few 9's.
⚠️ Pitfall: judging a cache by "does it feel faster"
Turn caching on, click around a few times, notice it feels faster, conclude "the cache works" β€” that's the easiest way to miss the real problem. The two numbers to actually watch are the hit ratio and the database queries per request. A cache with a 60% hit ratio is still "faster than no cache", but it's letting 40% of the load through to the database β€” and when traffic doubles, database load doubles right along with it, exactly when you thought the cache had you covered.

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.

Four cache patterns β€” numbered arrows show the ORDER things happen 1 Β· CACHE-ASIDE (read) app cache DB 1 GET 2 miss β†’ DB 3 app SETs the cache itself App knows about the cache Most common Β· still works if the cache dies 2 Β· READ-THROUGH (read) app cache DB 1 2 CACHE loads itself App does NOT know a cache exists Cleaner app code Cache dies = no path to the DB 3 Β· WRITE-THROUGH (write) app cache DB 1 write 2 writes next, SYNCHRONOUSLY Cache always matches the DB No staleness window Every write is slower 4 Β· WRITE-BEHIND (write) app cache DB 1 write, returns IMMEDIATELY 2 flushes LATER, asynchronously Fastest writes Can batch multiple writes Cache dies before flush = DATA LOSS The real tradeoff axis isn't "fast vs. slow" β€” it's: how much extra app complexity ↔ how much data loss is acceptable ↔ how stale data is allowed to get. Write-behind makes sense for a view counter; not for an account balance, even though it's the fastest.
Cache-aside is the most common pattern because it's the only one where a dead cache just slows the system down instead of stopping it.
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 β€” two branches, two very different costs HIT BRANCH β€” measured p50 = 1.45 ms client app cache (Redis) database 1 request 2 GET demo:k 3 has a value β€” done 4 responds not touched MISS BRANCH β€” measured p50 = 43.30 ms (30x slower) 1 request 2 GET demo:k 3 empty (miss) 4 queries the database β€” 40 ms (+ pool wait) 5 data 6 SET ... EX ttl β€” the APP itself reloads the cache 7 responds Step 4 is the entire problem of Lesson 5: if N requests reach step 3 at the same moment, step 4 happens N times β€” for the exact same piece of data (section 5.4).
The point to remember: in cache-aside, the app itself is responsible for writing the cache back (step 6). Nobody else does that job β€” and that's exactly why the app keeps working when the cache dies.

Cache-aside in the lab, exactly as the code that's actually running:

cache_aside.js
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:

versioned_key.js
// 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));
}
πŸ”¬ The staleness window under concurrent writes
Even with explicit delete, there's still a gap. This sequence can absolutely happen: request A reads a miss and fetches the old value from the database; request B writes the new value and deletes the cache; after that, A finally gets around to 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_jitter.js
// 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.

⚠️ My first jitter experiment was a failure
My first pass at this measurement used 2,000 keys and a 3-second TTL. Result: jitter made no difference at all β€” every metric matched within the run-to-run noise.

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.

πŸ”¬ Why p99 can't see the thundering herd
In that measurement, 1,100 requests got stuck in the database queue, out of roughly 280,000 total requests β€” that's 0.39%. The 99th percentile cuts off at 1%, so the entire affected group sits above p99: p99 never contains them, only the max latency reveals them.

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:

single_flight.js
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();
⚑ The number 54 reveals single-flight's limit
With a 1-second TTL and 18 seconds running (including warm-up), the key expires about 18 times. There are 3 replicas. 18 Γ— 3 = 54 β€” exactly the database query count measured, all three runs.

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.

⚠️ Pitfall: client retries make the herd worse
When the herd makes requests slow, clients often retry on their own. Every retry is a new request asking about the exact same missing key β€” the herd multiplies itself. The system goes from "slow" to "never recovers", and the ultimate cause turns out to be a mechanism that was added to increase reliability.

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:

watch_eviction.sh
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.

cache_key_good_bad.js
// ===== 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
πŸ’‘ Use an allowlist, not a blocklist
List the parameters allowed into the key (like 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

reproduce_measurements.sh
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

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

Related lessons in this series

Lesson 4: Reverse Proxy & API Gateway Lesson 6: CDN & Edge Caching Back to the System Design roadmap

Comments