The read-path cache, and what forty times faster costs
5 min read
A cache makes reads fast by keeping a copy of the answer. The copy is both the whole trick and the whole problem: the read is fast because it is a copy, and it is wrong for the same reason. The reel above climbs that ladder in 82.5 seconds — the copy, the lie, the timer, the stampede, and the one fetch that answers the stampede. This is the written version, with the formal names for what the animation carries in plain words.

The copy
The app answers a read by asking the cache first. If the cache holds the key, that is the answer and nothing else runs. If it does not — a miss — the app reads the database, and writes the copy into the cache on the way back.
That last clause is the entire mechanism. The read path is also the write path for the cache: a miss is what populates it. This shape is called cache-aside when the application owns the fetch, read-through when the cache library owns it; the sequence is the same either way.
The two figures the reel puts on stage — see the paragraph below for where they come from.
Both numbers are illustrative and order-of-magnitude correct, not telemetry: Redis serves reads in well under a millisecond to low single-digit milliseconds, and a relational query commonly costs tens of milliseconds. The reel's "40× faster" is not a third claim on top of them — it is 80 divided by 2, derived on stage from the two numbers you can see. Every figure in this article, the charts included, is that kind of number: the right size, not a reading off a dashboard.
Here is the whole read path with every fix already in it. The sections below take it apart in the order the reel does: what each part buys, and what it costs.
const TTL_SECONDS = 300; // 5:00
async function getUser(id: string) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached); // hit: ~2 ms, the database never hears about it
// singleFlight stands in for whatever coalescing primitive the stack provides —
// it is not a Redis client call
return singleFlight(key, async () => {
// only the first miss for this key runs this block; the rest await its result
const row = await db.query("select * from users where id = $1", [id]); // ~80 ms
await redis.set(key, JSON.stringify(row), { EX: TTL_SECONDS });
return row;
});
}The lie
Nothing in that read path updates the copy. Change the row in the database — a user renames themselves from Alan to Alana — and there are now two answers to the same question. The database holds the new one. The cache holds the old one. The app reads the cache, so the app serves the old one, and keeps serving it for as long as the copy exists.
The name for this is a stale cache, and it is the defining failure of the naive read-path cache rather than a bug in any particular implementation. The dangerous property is not that the copy is wrong; it is that the window is unbounded. Without something to remove it, the copy outlives the truth indefinitely, and nothing in the read path reports it — often the first signal is a user who can see their own old name.
The timer
The standard fix: every entry gets a time-to-live — TTL — when it lands. Five minutes, say. When the timer runs out the entry is evicted, the next read misses, and the miss refetches the current row from the database.
Be precise about what this buys. TTL does not make the cache correct; it converts unbounded staleness into a bounded number you chose. Inside the window the app can still serve a stale answer, and the trade is direct: a shorter TTL means fresher answers and more database reads, a longer TTL means fewer database reads and a longer period in which the app can be confidently wrong. There is no setting that is fresh and cheap at once, which is why the TTL is a product decision as much as an engineering one.
The stampede
The timer then sets its own trap.
Take a popular key — one row that a thousand concurrent readers all want. While the copy is on the shelf, the database sees no reads for it at all. Then the timer expires. Every one of those thousand readers misses at the same moment, every miss is defined as "go and read the database", and so a thousand queries arrive together for a row that has been costing the database nothing.
The reel's scenario: 1,000 readers at the moment one hot key expires.
This is a cache stampede, also called a thundering herd. What makes it bite is the flat line either side of the spike: incoming traffic never changed. For as long as the copy was on the shelf the database saw none of the reads for that key, and at the expiry moment it is handed all of them at once. The cache took down the database it was there to protect.
Only the first
The fix is to stop treating a thousand simultaneous misses as a thousand independent requests. Only the first miss for a key is allowed to reach the database. The other 999 wait on that one in-flight fetch, and when it returns they read the copy it left behind.
App
1,000 reads arrive for user:42, just after the copy expired
Cache
miss — the key isn't there
App
the first miss goes through; the other 999 wait on it
Database
one query, ~80 ms, one row
Cache
the copy lands again, with a fresh 5:00 timer
App
the 999 wake and read the copy — 2 ms each
Request coalescing: a thousand misses collapse into a single database read, and the refill answers all of them.
This is request coalescing, or single-flight. Note what it costs and what it does not. The 999 waiters still pay the latency of the one fetch — roughly the 80 ms of a single database read — but they all wait on that same fetch rather than each starting one of their own. The database's exposure at the expiry moment drops from a thousand reads to one.
Each fix on this ladder, up to the last one, creates the failure the next one has to answer. That is not a defect in caching, it is what caching is: the copy is the speed and the copy is the lie. The useful question is never whether to serve stale data, only how stale, for how long, and what happens at the moment the copy goes away.
What this leaves out
The ladder stops at coalescing because that is one mechanism per failure. The neighbouring problems are real and are not covered here:
- TTL jitter — the sibling fix for many keys expiring at the same moment rather than many readers missing one key. Coalescing collapses the misses for one key; it does not stagger the keys.
- Stale-while-revalidate — serving the expired copy while the refresh runs behind it, so nobody waits at all.
- Cache warming — populating keys before the first reader arrives.
- Write paths — cache-aside versus write-through, which is a different reel.
- Hot-key replication — spreading one very popular key across replicas.
What the reel claims
Every number and assertion on the canvas, and where it comes from.
| On-stage claim | Verification |
|---|---|
| DB read ~80 ms, cache read ~2 ms, 40× | Illustrative ballpark; 40 = 80/2, derived on stage; Redis sub-millisecond to low-millisecond reads are documented |
| The copy is written on the way back from a miss | The cache-aside / read-through read path, in all sources |
| The copy goes stale when the database row changes | Definitional for a read-path cache with no invalidation |
| A TTL evicts the copy; the next read refetches | Standard TTL semantics |
| A hot key expiring sends every reader to the database at once | Cache stampede / thundering herd |
| Only the first miss fetches; the rest wait and read the copy | Request coalescing / single-flight |





