An invalidation can arrive before an obsolete fill
A cache miss begins a database read. Before that read finishes, another request updates the database and invalidates the cache. The original read then returns its old value and puts it into the empty cache. The invalidation succeeded, but a later request can still receive stale data.
We reproduced that schedule with a small Python model. Its database has a version number, its cache holds a version and expiry time, and every event has an assigned timestamp. Six cases compare ordinary fills, an obsolete fill, a generation check and expiration. There are no threads, network calls or measured latency values.
| Schedule | Final read at ms | Database version | Returned version | Stale final read |
|---|---|---|---|---|
| Ordinary miss | 3 | 2 | 2 | No |
| Fill then invalidate | 3 | 2 | 2 | No |
| Obsolete fill | 4 | 2 | 1 | Yes |
| Generation fence | 4 | 2 | 2 | No |
| Before TTL expiry | 102 | 2 | 1 | Yes |
| At TTL expiry | 103 | 2 | 2 | No |
The third and fifth cases return version 1 after version 2 has committed. The generation-fenced case refuses the obsolete fill and fetches version 2 on the next read. Expiration also makes the next read fetch version 2, but only when the assigned expiry boundary is reached.
Version numbers in this example describe one record's successive values. They are not response times, cache hit rates or evidence that one cache product outperforms another.
Separate the write order from the in-flight read
The usual cache-aside read path checks the cache, loads a missing value from the data store and populates the cache. A write updates the data store and invalidates the affected cache entry. Microsoft's pattern guidance recommends that write order, while explicitly warning that cache-aside does not guarantee consistency. Azure cache-aside pattern.
Updating the database before invalidating avoids one common window: a miss should not refill from a database that the writer has not yet updated. However, this ordering cannot cancel a database read that already captured the previous value. That read may finish after the invalidation.
Our ordinary-miss case writes version 2, invalidates and then loads. It returns version 2. The fill-then-invalidate case caches version 1 before the write. Invalidation removes it, so the final read also returns version 2. Both schedules work under the model's rules.
The obsolete-fill case changes only the placement of the load completion. That small change is enough to expose the race. Testing only a complete read followed by a complete write would never exercise it.
Read the five events in order
At time 0, a loader captures database version 1 and generation 0. At time 1, the writer commits database version 2. At time 2, invalidation empties the cache and advances its generation to 1. At time 3, the old loader attempts to fill the cache. The final request reads at time 4.
A naive fill stores version 1 with an expiry of 103. The final read finds that entry and returns it. Nothing about the cache being empty at time 2 prevents the later insertion.
Redis documents a related client-side caching race in which an invalidation arrives on one connection before an older read reply arrives on another. Its example uses a placeholder to recognize whether the pending read was invalidated before caching the reply. That is a specific client-side protocol issue, with the same useful question: can an old reply repopulate state after invalidation? Redis client-side caching races.
Do not treat the local model as a Redis implementation. It has no connection ordering, server-assisted tracking or reconnection behavior. The documentation supplies a real protocol example. Our explicit schedule isolates the stale-fill mechanism.
Reject a fill whose generation has changed
The model's optional fence captures the cache generation when a load starts. Before storing the returned value, it compares the captured generation with the current one. A mismatch means an invalidation happened during the load, so the cache rejects that fill.
In the fourth schedule, the old loader holds generation 0 while the cache is at generation 1. Its fill is rejected. The next read misses, loads version 2 and stores it under generation 1. The final read therefore returns version 2.
The check and insertion must form one indivisible operation within the authority that owns this cache state. A separate check followed by an unguarded write creates another opportunity for invalidation to slip between them. Our single Python object supplies that atomic step by construction. It does not demonstrate how multiple processes should coordinate it.
Generation lifetime matters too. If eviction removes the generation and recreation resets it to an earlier value, a delayed loader might mistake a new cache lifetime for its old one. A production design needs a token or version scheme that cannot accidentally reuse a still-live loader's identity.
This fence protects the shared cache from the obsolete reply. It does not retroactively change the value already read by the request that started at time 0. A requirement that every response reflect a later completed write needs a stronger, explicitly defined consistency contract.
Expiration limits a cache residence window
Time to live, or TTL, is measured from the fill in this model. The obsolete value arrives at time 3 and receives a 100 ms TTL. It remains eligible at time 102 and expires at time 103. The stale window is therefore related to the late fill, not simply to the database commit at time 1.
At expiry, the model removes the entry before checking for a hit. The resulting miss immediately reads the current database version and refills the cache. That is why the sixth case returns version 2.
These rules are deliberately small. A stale replica, another delayed loader or a failed invalidation can extend or recreate staleness in a real system. Repeatedly refreshing an entry's TTL on access would also change the result. State which event starts and renews the expiry clock before describing a maximum age.
Shortening TTL can increase database reads. The right value depends on the cost of misses and the product's tolerance for old values. The 100 ms fixture makes the boundary arithmetic easy to inspect. It supplies no workload evidence for choosing a production TTL.
Define the cache key and the failure policy
A perfectly ordered invalidation is still wrong if it targets the wrong key. Include every input that changes the cached representation: the record identity, relevant tenant scope and any authorization-dependent variation. Decide whether several representations of one record require several invalidations.
Permission decisions deserve particular care. An old cached decision can remain useful to an attacker after access has been revoked. Identify which operations may tolerate stale display data and which must consult authoritative policy state. The model tests record versions, not authorization safety.
Local caches add another boundary because each application instance can hold a different value. Microsoft's guidance calls out this divergence. A writer invalidating its own process does not establish that every other process received or applied the same event.
When invalidation depends on a message, define what happens after missed delivery or reconnect. A durable handoff can preserve the obligation to notify another component. Our transactional outbox crash cases explain why a queued obligation and a completed consumer effect are different facts. That handoff alone does not provide a cache consistency guarantee.
Reproduce the schedules and extend one boundary
Download the Python model, six-row CSV and event traces. Save the source as experiment.py and execute it with Python 3. It uses only the standard library.
python3 experiment.pyThe script runs all schedules twice, checks identical results and asserts the returned-version sequence: 2, 2, 1, 2, 1, 2. It also verifies that only the generation-fenced case rejects a fill. Assigned timestamps make expiry behavior reproducible without sleeping.
For an implementation test, first preserve the event order with explicit barriers around the database load and cache fill. Then add your actual cache adapter. A test based only on repeated requests and random delays can pass without ever reaching the relevant window.
| Extension | Question to answer | Evidence to retain |
|---|---|---|
| Multiple loaders | Can an older reply overwrite a newer fill? | Load tokens and resulting entry version |
| Multiple instances | Which caches observe the invalidation? | Per-instance receipt and cache state |
| Reconnect | What state survives a tracking interruption? | Connection lifecycle and invalidation policy |
| Eviction | Can a generation identity be reused? | Token lifetime across removal and recreation |
| Read replicas | Can a fresh miss load an old value? | Database source and returned version |
Use these cases to define what a successful invalidation means for your application. Record versions and event order in the assertions. A successful delete operation proves that an entry was removed at one point. The later fill is a separate event that the test must account for.
Sources
Documentation checked .
