Interlude · Spirit
The Three Reading Techniques
The question
Every read has the same shape: the data might be in the cache, or it might not. What happens on a miss?
There are three answers, and the difference between the first two is who is responsible.
Cache-aside
Cache-aside — also called lazy loading — means your application manages the cache directly. It is the most common pattern in use, and the default you should assume unless there is a reason otherwise.
value = cache.get(key)
if value is not None:
return value # HIT
value = database.query(key) # MISS — application fetches
cache.set(key, value, ttl=300) # application stores
return value
The application talks to both the cache and the database, and it owns every step.
Why people use it:
- Only requested data is cached. Nothing is stored that nobody wants.
- If the cache is unavailable, the app can fall back to the database, as long as the database can handle the extra load.
- It works with any cache and any database. No special library required.
- You can see exactly what is happening.
The costs:
- Every miss pays three trips: check cache, query database, write cache.
- The logic is repeated everywhere you cache something, and someone will eventually get it wrong.
- It has a specific race condition, which is the second half of this lesson.
Read-through
Read-through means your application only ever talks to the cache. The cache knows how to load missing data by itself.
value = cache.get(key) # cache handles the miss internally return value
You configure the cache with a loader function — the code that fetches from the database — and the cache calls it when it needs to.
The distinction is exactly this:
| Who handles the miss | |
|---|---|
| Cache-aside | Your application |
| Read-through | The cache layer |
That is the whole difference. Not performance, not freshness — responsibility. And because it decides where the logic lives, it decides where the bugs live.
Why people use it: the loading logic exists once, not scattered everywhere. Application code becomes simple. Many caching libraries do the hard parts (like the race below) for you.
The costs: you need a library that supports it. The cache going down is now harder to survive, because your app has no other path to the data. And the loading logic is one step further from the code that uses it.
You have already met one. In Lesson 4, the CDN fetched from origin on a miss without your application knowing. A CDN is a read-through cache. You did not implement it, so it did not feel like a pattern. It was.
Refresh-ahead
Both patterns above are reactive: a user hits a miss, and that user waits while the data loads.
Refresh-ahead is proactive. For values you know are hot, refresh them before they expire, so no user ever encounters the miss.
TTL = 300 seconds, refresh at 240
t=0 value cached
t=240 ─→ background job refreshes it (no user is waiting)
t=245 value replaced, timer resets
...no user ever sees a miss on this key
Why people use it: for hot keys, misses effectively disappear. Your p99 latency — the slow tail you will meet in Lesson 13 — improves dramatically, because the tail was the misses.
The costs: you refresh values that may never be requested again, so you do work for nothing. You need to identify which keys deserve it. Applying it to everything is a way to generate constant pointless load.
You have seen a related idea in Lesson 3.
stale-while-revalidate serves a stale copy while fetching a
fresh one. Refresh-ahead starts before the copy expires.
Which realm uses which
| Realm | Natural pattern | Why |
|---|---|---|
| Browser | Cache-first with revalidation | Controlled by headers, not code |
| CDN | Read-through | Fetches from origin on a miss automatically |
| Local cache | Cache-aside or memoization | You wrote it, you own it |
| Redis | Cache-aside | The usual default |
| Database buffer pool | Read-through | The database handles its own misses |
Notice the pattern: when you own the code, you get cache-aside. When someone else built the cache, you get read-through. The techniques are the same everywhere; only the ownership moves.
The race: many readers, one missing key
Here is the flaw in cache-aside, and it is not a rare edge case — it is a guaranteed event on any popular key.
The front-page product is requested 1,000 times per second. Its cached value expires.
t=0.000 TTL expires
t=0.001 Request 1 → miss → queries database
t=0.001 Request 2 → miss → queries database
t=0.001 Request 3 → miss → queries database
...
t=0.001 Request 1000 → miss → queries database
t=0.050 first query returns, cache is written
— 49 milliseconds too late for the other 999
The cache did not fail. Every one of those requests followed cache-aside correctly. The pattern has a gap between “the value expired” and “the value has been reloaded,” and every request arriving in that gap goes to the database.
Worse, this is self-amplifying. A thousand simultaneous queries slow the database, which makes the reload take longer, which widens the gap, which lets more requests through.
Single-flight
Request coalescing — usually called single-flight — fixes it: when many requests need the same missing key, one of them fetches it, and the rest wait for that one result.
t=0.001 Request 1 → miss → acquires the loader slot → queries database t=0.001 Request 2 → miss → someone is loading → waits t=0.001 Request 3 → miss → someone is loading → waits ... t=0.050 Request 1 gets the value, stores it, and hands it to all 999 waiters
One database query. All 1,000 requests served.
The shape:
def get(key):
value = cache.get(key)
if value is not None:
return value
# only one caller per key gets past this point;
# the others block here and receive the same result
with single_flight(key):
value = cache.get(key) # check again — someone may have
if value is not None: # filled it while we waited
return value
value = database.query(key)
cache.set(key, value, ttl=300)
return value
Two details that matter:
-
The second
cache.getis not redundant. By the time a waiter is released, the value usually exists. Checking again avoids a pointless second query. This is the double-checked pattern. - The lock is per key, not global. A global lock would make every miss on every key wait behind every other, which would be far worse than the problem.
Most mature caching libraries provide this. It is worth knowing it exists before you need it — and you are about to need it.
Remember this. This is your first weapon against the Latency Lord, and it is deliberately being handed to you four lessons before the fight.
At the Storefront
Cache-aside for product details, with single-flight — it is the front page item and it will stampede.
Refresh-ahead for the category tree — small, hot, expensive, on every page.
Nothing for the cart badge, in any pattern.
“Cache-aside and read-through are the same thing.”
They differ in who owns the miss, and that decides who owns the race condition above. With cache-aside, it is yours to solve. With read-through, your library probably solved it — which is a genuine reason to prefer one.
The Latency Lord has been watching the reading techniques, and he has found the gap.
Not a flaw in the cache. A gap in time — the space between the moment a value expires and the moment it is restored. It is small. Fifty milliseconds, perhaps.
A thousand requests can arrive in fifty milliseconds.
He does not need to take the cache away. He only needs to choose the moment it empties.