The Runtime Theory
SystemDSAarchitecture

Cache Miss: The Full Path from Miss to DB to Populate to TTL

A step-by-step walk from the cache GET that misses to the database query, the populate, and the TTL that sets the stampede window.

The Runtime Theory Team2 min read06 steps

trace spine

  1. 01 cache GET misses
  2. 02 DB query executes
  3. 03 response cached with TTL
  4. 04 TTL expires
  5. 05 stampede window opens
  6. 06 single-flight protects the DB

The cache miss is the most important event in a cached system: it is the moment the database gets hit, and under load it's the moment the database can die. Here is the literal path of one miss.

trace stepSystem
The app executes GET user:42 against Redis. One round trip, ~0.5ms. The key doesn't exist — either it was never cached or its TTL expired. The miss itself is cheap; the cost is everything that follows, because the request is now a database query in disguise.
trace stepSystem
The app runs SELECT ... WHERE id = 42 — a point lookup using the primary key, ~1–5ms on a warm buffer pool, 10–20ms if the page must be read from disk. If this is one of 1,000 concurrent misses for the same key (a popular item, all expiring together), the database sees 1,000 identical queries at once — a stampede.
trace stepSystem
The row comes back, and the app writes SET user:42 <value> EX 60 — one more ~0.5ms round trip. The TTL is the contract: the data is allowed to be up to 60 seconds stale. From now until expiry, every read is a cache hit at ~0.5ms and the database is untouched for this key.
trace stepSystem
Sixty seconds later the key is deleted (Redis evicts lazily on access or actively on the expiry scan, every ~100ms). The next request for user 42 is a miss again. This is not a bug — it is the refresh cycle that keeps the cache from growing stale forever.
trace stepSystem
If traffic for this key is high — say 500 req/s — the moment of expiry is followed by a burst of simultaneous misses: 500 identical DB queries inside the same ~50ms. The database, which was seeing ~0 qps for this key, suddenly gets 500. That is the stampede, and it is fully self-inflicted: the TTL synchronized all the refreshes.
trace stepSystem
The fix runs between step 1 and step 2: before querying the DB, the app checks a per-key in-flight map (or Redis lock). Only the first misser queries the DB; the other 499 wait on its result and share the populated cache entry. DB queries collapse from 500 to 1; the cost is a lock or atomic op per miss (~0.1ms).
text
v = GET user:42
if v is nil:
    with singleFlight(key="user:42"):     # one query per key
        v = db.query("user", 42)
        SET user:42 v EX 60
return v

The economics: each miss costs ~1ms of DB time plus ~1ms of Redis round trips, but a stampede multiplies that by the concurrent request rate. The healthy system runs at 95%+ hit rate — 5% of reads become DB reads, and single-flight keeps even those at one query per key per TTL window. The cache isn't a speedup; it's a filter, and the miss path is where the filter is enforced.