Read-through caching gives callers one reading interface: a hit returns a stored value, while a miss invokes a loader and stores its result. The important boundary is who owns that loader. Moving it behind the cache interface can remove duplicated lookup logic, but does not automatically solve concurrency, writes, authorization, or consistency between independent copies.
Identify the loading layer
In cache-aside, application code explicitly checks the cache, queries the source on a miss, and fills the cache. With read-through, the caching component owns that miss path. A library can provide this interface inside an application, or a service can provide it remotely. Those deployment choices still determine which processes need updating when loader behavior changes.
A Redis GET alone is not a read-through loader. For a missing string key it returns nil; it does not discover and query your database. A client library, caching service, or application adapter must supply the loading behavior. The exercise below uses a memoizing function wrapper as that interface, not Redis, and keeps the source as an in-memory fixture.
Loading does not define writing
Read-through says nothing about where writes go. Write-through is a separate arrangement, and even a shared read/write component must define what happens when source updates and cache updates fail independently. Writers that bypass it can leave cached values unchanged. Identify all real update paths instead of assuming the pattern makes every writer participate.
TTL restricts reuse of a particular cached entry; it does not guarantee that a later reload obtains current truth from a lagging replica or buggy loader. Nor does expiration necessarily remove bytes from memory immediately. Choose lifetimes from actual data requirements. A short TTL on user-specific data is not permission isolation, and a high hit ratio cannot establish correctness.
When it breaks
An absent record and an unavailable source are different results. Caching a legitimate absence can reduce repeated misses, but can also hide a record created afterward until expiry or invalidation. Converting a timeout into an empty successful result can therefore create a poisoned entry. Decide which outcomes are cacheable and how callers distinguish absence from failure.
Concurrent loading is another independent contract. Cachetools documents that a lock protects cache access while the wrapped function executes outside it; a lock alone does not suppress duplicate loading. Its condition option addresses same-key concurrent execution. The single-threaded exercise deliberately uses neither and must not be treated as a thread-safe or distributed implementation.
Invalidation can race with a load already reading an older source version. Removing a key does not inherently prevent that pending result from being inserted later. Select coordination appropriate to the real writer and loader protocol, and test their ordering. Avoid broad key-pattern deletion as a default response: it creates cold entries without proving that the underlying defect is fixed.
Check it quickly
Use Python 3.10 or newer; this ran with Python 3.14.4 and cachetools 7.1.8. Install the pinned dependency, then run the Python block with "$readthrough_lab/venv/bin/python". The library implements storage, expiry, and memoization. A manually advanced test clock makes the deadline deterministic without sleeping or changing system time. No Redis server or external database is contacted.
readthrough_lab=$(mktemp -d)
python3 -m venv "$readthrough_lab/venv"
"$readthrough_lab/venv/bin/python" -m pip install cachetools==7.1.8
from collections import Counter
from cachetools import TTLCache, cached
clock = [0]
cache = TTLCache(maxsize=8, ttl=10, timer=lambda: clock[0])
source = {"tea": 100}
failures = set()
loads = Counter()
@cached(cache=cache, info=True)
def price(sku):
loads[sku] += 1
if sku in failures:
raise OSError("source unavailable")
return source.get(sku)
assert price("tea") == price("tea") == 100
assert loads["tea"] == 1
source["tea"] = 120
clock[0] = 9
assert price("tea") == 100 and loads["tea"] == 1
clock[0] = 10
assert price("tea") == 120 and loads["tea"] == 2
print("hit does not extend TTL: old value at t=9; reload at t=10")
source["tea"] = 130
cache.pop(price.cache_key("tea"))
assert price("tea") == 130 and loads["tea"] == 3
print("explicit eviction: next read loads the updated source")
assert price("coffee") is None
source["coffee"] = 200
assert price("coffee") is None and loads["coffee"] == 1
cache.pop(price.cache_key("coffee"))
assert price("coffee") == 200 and loads["coffee"] == 2
print("None is cached: newly created data needs expiry or invalidation")
cache.pop(price.cache_key("tea"))
failures.add("tea")
for _ in range(2):
try:
price("tea")
except OSError:
pass
else:
raise AssertionError("failure was hidden")
assert loads["tea"] == 5
assert price.cache_key("tea") not in cache
failures.remove("tea")
assert price("tea") == price("tea") == 130
assert loads["tea"] == 6
print("exceptions are not cached: two failed loads, then one recovery load")
print(price.cache_info())
The first two reads execute one loader. Changing the source does not change the still-live cached price: it remains 100 at time nine. At time ten the entry expires, and the wrapper loads 120. This checks expiration after insertion, not sliding expiration after access. Explicitly removing the wrapper’s actual cache key then exposes the next source update, 130.
The missing coffee price is cached as None. Creating the source record alone does not replace that cached absence; explicit removal allows a subsequent load of 200. Two raised source errors each invoke the loader and leave no cached tea entry. After recovery, one successful load supplies two reads. The reported totals are four hits and eight misses, including failed loads.
These assertions prove this wrapper’s observed miss, expiry, absence, and error behavior. They do not prove atomic writes, concurrent miss suppression, eviction under memory pressure, or distributed consistency. For a real service, record loader counts and outcomes alongside cache statistics; two faster HTTP requests or global Redis hit counters cannot identify which application loader actually ran.
Further reading
- Cache headers are a contract with two clocks
- Validators decide whether a stored copy is still the answer
Sources
- Cachetools documentation, TTLCache, cached, synchronization options, and exception handling, checked 2026-09-07.
- Redis GET, missing keys return nil rather than loading another data source, checked 2026-09-07.
- AWS Builders’ Library: caching challenges and strategies, cold caches, negative caching, and source-load risks, checked 2026-09-07.