What is a read-through cache?
A read-through cache is a caching pattern in which the cache sits inline between the application and the database and takes responsibility for loading data itself. When a requested key is missing, the cache calls a loader function, fetches the value from the underlying data source, stores it, and returns it to the caller. The application only ever talks to the cache.
That single design decision — moving the load logic out of the application and into the cache — is what separates read-through caching from the more common cache-aside pattern. This page covers how it works, how it compares to cache-aside, write-through and write-around, when it is the right choice, and how to implement it in Java on Valkey and Redis.
What is a read-through cache?
In a read-through cache, the cache is the only data access point the application knows about. A read is a single call:
value = cache.get(key)
Whether that value came from memory or from a database query issued moments earlier is invisible to the caller. The cache owns the decision, the query, and the population step.
Contrast this with the alternative most applications start with. In cache-aside, the application checks the cache, and on a cache miss it queries the database itself and then writes the result back. The cache is a passive store the application manages around. In read-through caching, the cache is an active participant that manages itself.
A note on terminology: the pattern is written variously as read-through cache, read through cache and readthrough cache. All three refer to the same thing. Some caching products and papers also call it a cache loader or inline cache.
Read-through is a read-path pattern only. It says nothing about what happens on a write, which is why it is nearly always paired with a write pattern such as write-through or write-behind.
How read-through caching works
Every read resolves down one of two paths.
On a cache hit, the cache holds the key and returns the value immediately. No database involvement, no loader invocation.
On a cache miss, the cache invokes the registered loader, which queries the system of record. The returned value is stored in the cache and handed back to the caller. The next request for that key is a hit.
The important detail is what the application does not do. There is no if (value == null) branch, no repeated database call at every call site, and no risk that one developer forgets to populate the cache after a miss. The loader is registered once and every read path in the application inherits it.
The cost is a loss of granular control. When the cache decides to call the loader, the calling thread waits, and the behaviour of that call — timeouts, retries, exception handling — is governed by the cache's configuration rather than by code you can see at the call site.
Read-through vs cache-aside
These two patterns produce the same result for the caller and differ entirely in who does the work. It is the most common point of confusion in this area, so it is worth being precise.
In cache-aside (also called lazy loading), the application orchestrates:
Product product = cache.get(id);
if (product == null) { // cache miss
product = productRepository.findById(id);
if (product != null) {
cache.put(id, product); // populate for next time
}
}
return product;
In read-through, the same logic lives inside the cache, and the application reads:
Product product = cache.get(id); // loader runs on a miss
The practical consequences:
- Boilerplate. Cache-aside repeats the miss-handling block at every call site. Read-through defines it once. In a codebase with dozens of read paths against the same entity, this is the difference between one loading strategy and dozens of slightly divergent ones.
- Consistency between callers. With cache-aside, every call site decides for itself how to handle a miss. One that forgets to populate the cache, or writes it back with a different TTL, quietly diverges from the rest. Read-through gives every caller the same load path by construction.
- Control. Cache-aside lets you vary loading behaviour per call site — a different query, a projection, a bulk fetch. Read-through gives you one loader per cache.
- Flexibility of source. Cache-aside can cache anything from anywhere, including computed values that have no single backing query. Read-through assumes a key maps to a fetchable record.
Neither is superior. In a cache-aside vs read-through decision, cache-aside is the sensible default because it requires nothing of the cache. The pattern earns its place when the boilerplate becomes a maintenance problem or when you want a guarantee that no code path skips the cache.
Read-through vs write-through
A great deal of confusion comes from treating read-through and write-through as competing options. They are not alternatives. They govern opposite directions of traffic and are routinely used together.
- Read-through controls what happens on a read miss: the cache loads from the database.
- Write-through controls what happens on a write: the cache synchronously persists to the database before the call returns.
Configuring both gives you a cache that fully mediates access in both directions — the application talks only to the cache, and the cache keeps itself and the database in step. That combination is what most people actually mean when they describe a cache as "read-through and write-through."
The trade-offs are separate too. Read-through costs you latency on a miss, which affects reads that were going to be slow anyway. Write-through costs you latency on every write, in exchange for the cache and database never diverging. If write latency is the constraint, the usual substitution is write-behind, which batches writes asynchronously and accepts a window of inconsistency and a risk of loss on crash.
Read-through vs write-around and refresh-ahead
Two related patterns come up alongside read-through and are worth distinguishing.
Write-around writes directly to the database and skips the cache entirely, leaving the entry to be populated on the next read. It pairs naturally with read-through: writes bypass the cache, and the next read repopulates it on demand. This suits data that is written far more often than it is read, where caching every write would fill the cache with entries nobody requests.
Refresh-ahead is read-through with anticipation. Instead of waiting for an entry to expire and taking the miss penalty on the next request, the cache proactively reloads entries that are approaching expiry and have been accessed recently. It removes the latency spike at the cost of loading data that may never be asked for again.
Caching pattern comparison
| Pattern | Who loads on a miss | Write behaviour | Best for | Main risk |
|---|---|---|---|---|
| Cache-aside | The application | Application writes the database, then invalidates the cache | General-purpose read-heavy workloads where you want full control | Stale entries from invalidation races; inconsistent handling across call sites |
| Read-through | The cache, via a loader | Defined separately — pair with a write pattern | Removing load boilerplate; guaranteeing every read goes through the cache | Less control over load behaviour; stampede risk on cold keys |
| Write-through | Usually read-through | Synchronous database write before the call returns | Data where the cache and database must not diverge | Every write pays for a database round trip |
| Write-behind | Typically read-through | Asynchronous batched write after a delay | Write-heavy or bursty workloads that tolerate small lag | Unflushed changes are lost if the cache dies; the database lags behind |
| Write-around | Usually read-through | Application writes the database; cache is not populated | Write-heavy data that is read rarely | Every first read after a write is a miss |
When to use a read-through cache
Read-through is a good fit when:
- The workload is read-heavy and the same entities are requested repeatedly. This is the precondition for caching at all rather than an argument for read-through specifically, but it is where the pattern earns its keep: one loader call per key per cache entry lifetime, however many call sites ask for it.
- Many call sites read the same entity. Centralising the load logic eliminates drift between them.
- A key maps cleanly to a record. The loader has to be able to resolve any key to a value from a single source.
- You want one load path rather than many. A registered loader is the single route by which data enters the cache, so miss handling cannot drift between callers. It does not prevent code from querying the repository directly — nothing does — but it removes the reason to.
- You are already using a caching framework. Spring Cache, JCache, Hibernate and MyBatis all expose read-through as configuration rather than code.
Prefer cache-aside when different call sites need different loading behaviour, when you cache computed or aggregated values with no single backing query, or when you want the load path visible and debuggable in application code.
Read-through caching pitfalls
Cache stampede on cold keys. The most serious failure mode. When a hot key expires or the cache restarts, every concurrent request for that key misses simultaneously and each one triggers the loader. One miss becomes thousands of identical database queries. This is not unique to read-through, but its transparency makes it easier to overlook — see the thundering herd problem for mitigations including distributed locking, TTL jitter and probabilistic early recomputation.
Stale entries. A loader populates the cache but nothing removes the entry when the underlying record changes. Without a TTL or an explicit invalidation path, the cache will happily serve outdated data indefinitely. Always set a TTL as a backstop even when you also invalidate on write.
Loader failures become read failures. Because the cache calls your loader synchronously, a slow or failing database turns every cache miss into a slow or failing read. Set timeouts and decide deliberately whether a loader exception should propagate or return a null.
Caching absence. If the loader returns nothing for a key, most caches store nothing — so every subsequent request for that nonexistent key hits the database again. Where lookups by user-supplied identifiers are common, consider caching the absence explicitly or fronting the cache with a Bloom filter.
Read-through caching in Valkey and Redis
Redis and Valkey are in-memory data structure stores widely used as caches. Neither implements read-through natively — the GET command returns nothing on a miss and has no concept of a loader. The pattern has to be provided by the client library.
In Java, Redisson supplies it through MapLoader. Attach a loader to any RMap and a missing key is fetched, cached and returned without the caller doing anything, which collapses the cache-aside boilerplate into read-through behaviour:
MapLoader<String, Product> loader = new MapLoader<>() {
@Override
public Product load(String key) {
return productRepository.findById(key); // called only on a miss
}
@Override
public Iterable<String> loadAllKeys() {
return productRepository.findAllIds(); // used for preloading
}
};
MapOptions<String, Product> options = MapOptions.<String, Product>defaults()
.loader(loader);
RMap<String, Product> products = redisson.getMap("products", options);
Product product = products.get("sku-123"); // loads from the database on a miss
Because RMap implements java.util.concurrent.ConcurrentMap, the read path stays an ordinary map lookup. Use RMapCache instead of RMap when you need per-entry TTL and max-idle as a staleness backstop.
Adding a MapWriter to the same MapOptions gives you a write pattern alongside read-through, so a single configuration object defines both directions of traffic. The write-through and write-behind page covers that side in detail.
For the fastest possible reads, RLocalCachedMap puts a near cache inside the application process in front of the distributed map. The loader still applies, producing a two-tier read-through: a local miss checks Valkey or Redis, and a full miss calls the loader.
LocalCachedMapOptions<String, Product> options =
LocalCachedMapOptions.<String, Product>defaults()
.cacheSize(10_000)
.evictionPolicy(LocalCachedMapOptions.EvictionPolicy.LRU)
.syncStrategy(LocalCachedMapOptions.SyncStrategy.INVALIDATE)
.timeToLive(Duration.ofMinutes(10))
.loader(loader);
RLocalCachedMap<String, Product> products =
redisson.getLocalCachedMap("products", options);
The INVALIDATE sync strategy tells other application instances to drop their local copies when an entry changes, which is what makes a near cache safe in a distributed system — see client-side caching for how the invalidation works.
The same pattern is available through the standard Java caching frameworks that Redisson implements. JCache (JSR-107) exposes it as setReadThrough(true) with a CacheLoaderFactory; Spring Cache, the Hibernate second-level cache and the MyBatis second-level cache all provide equivalent loading hooks at the framework level.
For working code covering all four patterns end to end, see Java caching strategies with Valkey and Redis.
Frequently asked questions
What is a read-through cache?
A read-through cache is a caching pattern where the cache loads missing data from the underlying database itself, rather than the application doing it. The application reads only from the cache; on a miss, the cache invokes a registered loader, stores the result, and returns it.
What is the difference between read-through and cache-aside?
Both fetch from the database on a miss and populate the cache. The difference is who does it. In cache-aside, the application checks the cache, queries the database on a miss, and writes the result back. In read-through, that logic is registered with the cache once and runs inside it, so the application makes a single get call.
Is read-through the same as write-through?
No, and they are not alternatives. Read-through governs the read path — the cache loads from the database on a miss. Write-through governs the write path — the cache synchronously persists to the database before returning. They are commonly configured together on the same cache.
Does Redis support read-through caching natively?
No. Redis and Valkey have no built-in loader concept; a GET on a missing key simply returns nothing. Read-through is implemented by the client library. In Java, Redisson provides it through MapLoader on RMap, RMapCache and RLocalCachedMap, and through CacheLoader for JCache.
How do you implement read-through caching in Java?
Implement a loader that resolves a key to a record from your data source, then register it with the cache. With Redisson, implement MapLoader and attach it via MapOptions.loader(). With JCache, set setReadThrough(true) and supply a CacheLoaderFactory. After that, an ordinary get triggers the load on a miss.
What are the drawbacks of a read-through cache?
You give up per-call-site control over how data is loaded, a slow or failing loader turns every miss into a slow or failing read, and a hot key expiring under load can trigger a cache stampede in which many concurrent misses all call the loader at once. A TTL, timeouts, and stampede protection address these.