What is the cache-aside pattern?
Cache-aside is a caching pattern in which the application, not the cache, is responsible for talking to the database. On a read the application checks the cache first; on a cache miss it queries the database, writes the result into the cache, and returns it. The cache sits aside the main data flow and never contacts the database itself.
It is the most widely implemented caching pattern and the one most applications should start with, because it requires nothing of the cache beyond get and set. This page covers how the read and write paths work, the three failure modes that catch teams out, how to choose a TTL, and how to implement the pattern in Java on Valkey and Redis.
What is the cache-aside pattern?
In cache-aside, the cache is a passive key-value store that the application manages around. Every read path contains the same three-step logic: look, fall through, populate.
value = cache.get(key)
if value is null:
value = database.query(key)
cache.set(key, value, ttl)
return value
Because the application owns that logic, it can cache anything — a database row, the result of an expensive join, a rendered fragment, a value assembled from three services. Nothing needs to map cleanly onto a single query.
A note on terminology: cache-aside is also called lazy loading, because entries are only ever created in response to a real request, and look-aside caching. All three names describe the same pattern. It is occasionally confused with read-through caching, where the same fall-through logic lives inside the cache rather than in your code.
How cache-aside works
On a read, the application asks the cache for the key. A hit returns immediately. A miss sends the application to the database, and the returned value is written back into the cache before it is handed to the caller, so the next request for that key is a hit.
On a write, the application updates the database and then removes the cached entry — invalidation rather than replacement. Deleting is safer than overwriting: a delete is idempotent and the next read will repopulate from the source of truth, whereas an overwrite can seed the cache with a value that a concurrent transaction has already superseded.
Cache-aside vs write-through
The caching patterns differ along two axes: who talks to the database, and when the database is written. Cache-aside answers the first with "your application" and leaves the second entirely to you — which is what separates it from write-through caching. The two are often presented as alternatives, but what actually divides them is the write target. In cache-aside the application writes the database and then deletes the cached entry; the cache is never written to directly. In write-through the application writes to the cache, and the cache persists to the database synchronously before the call returns.
Four consequences follow. Write-through keeps the two stores in step at all times, so it has no equivalent of the invalidation race described below. It pays for that on every write, which now carries a database round trip on the critical path. It also populates entries on write regardless of whether anyone reads them, which wastes memory on write-heavy data with a cold read pattern — the opposite of cache-aside, which only ever holds what has actually been requested. And if the cache becomes unavailable, cache-aside degrades to reading the database while write-through blocks writes outright.
Choose write-through when the cache and database must not diverge and writes are relatively infrequent. Choose cache-aside otherwise. One terminology note: cache-through is sometimes used loosely for any arrangement where the cache itself talks to the database — read-through and write-through together — so "cache-aside vs cache-through" is usually this same comparison under a different name.
Where cache-aside goes wrong
The pattern is simple to describe and easy to get subtly wrong. Three failure modes account for most production incidents.
The write-invalidate race
A database write and a cache invalidation are two separate operations, and a reader can slip between them. A common interleaving: reader A misses and queries the database; writer B updates the row and deletes the cache entry; reader A then writes its now-stale value into the cache, where it survives until the TTL expires.
Nothing in the pattern prevents this. Shortening the TTL bounds how long the stale value lives, and writing through a lock on the key closes the window properly. See cache invalidation for the full set of strategies.
Thundering herd on expiry
Because entries are created lazily, a popular key that expires leaves every concurrent reader with a miss at the same instant — and all of them query the database together. A cache that was absorbing thousands of reads per second hands the entire load to the database in one burst. This is the thundering herd problem, and it is a property of lazy loading rather than a bug in any particular implementation. The standard fix is to let one caller repopulate while the rest wait, which needs a distributed lock.
Cold start
An empty cache serves nothing. After a deploy, a failover or a flush, every read is a miss until the working set is rebuilt, and the database sees full traffic throughout. If it cannot survive that, warm the critical keys on startup or stage the restart.
Choosing a TTL
Almost every cache-aside entry should expire. The TTL is not a performance setting; it is a statement about how stale a value is allowed to become, and it doubles as the backstop for the invalidation race above.
Set it when you write the key rather than leaving it to eviction, which fires under memory pressure — not the same thing as data going out of date. Where correctness matters more than hit rate, pair a longer TTL with explicit invalidation on write. Where it does not, a short TTL and no invalidation is often the whole design.
Implementing cache-aside in Java with Redisson
Valkey and Redis have no notion of a cache pattern; they store keys. Cache-aside is therefore implemented entirely in your application, which is why it works with any client. Redisson exposes the store through familiar Java interfaces, so the cache reads as a Map rather than a sequence of commands.
The baseline is a plain RMap:
RMap<String, Product> cache = redisson.getMap("products");
public Product getProduct(String id) {
Product cached = cache.get(id);
if (cached != null) {
return cached; // cache hit
}
Product fromDb = productRepository.findById(id); // cache miss
if (fromDb != null) {
cache.fastPut(id, fromDb); // populate for next time
}
return fromDb;
}
That version has no expiry. RMapCache adds a TTL per entry — and optionally a maximum idle time — without leaving the Map interface:
RMapCache<String, Product> cache = redisson.getMapCache("products");
// per-entry TTL
cache.put(id, fromDb, 10, TimeUnit.MINUTES);
// TTL plus a maximum idle time
cache.put(id, fromDb, 30, TimeUnit.MINUTES, 5, TimeUnit.MINUTES);
RMapCache runs on any server version, because Redisson evicts expired entries with a client-side task. On Redis 7.4 or later, or Valkey 9.0 or later, RMapCacheNative delegates expiry to the server's native hash-field TTL commands instead, so there is no eviction loop to fall behind — which matters for cache-aside, since an entry that outlives its TTL is exactly the stale read the pattern is trying to bound:
RMapCacheNative<String, Product> cache = redisson.getMapCacheNative("products");
cache.put(id, fromDb, Duration.ofMinutes(10));
To close the thundering herd, let one caller rebuild the entry while the others wait on an RLock rather than piling onto the database:
RMapCache<String, Product> cache = redisson.getMapCache("products");
public Product getProduct(String id) throws InterruptedException {
Product cached = cache.get(id);
if (cached != null) {
return cached;
}
RLock lock = redisson.getLock("rebuild:product:" + id);
// no leaseTime argument, so Redisson's watchdog renews the lock while
// this instance is alive and a slow rebuild cannot release a second herd
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
cached = cache.get(id); // the winner may have populated it
if (cached != null) {
return cached;
}
Product fromDb = productRepository.findById(id);
cache.fastPut(id, fromDb, 10, TimeUnit.MINUTES);
return fromDb;
} finally {
lock.unlock();
}
}
// lock not acquired inside the wait window: read once more and degrade,
// rather than adding this request to the pile on the database
return cache.get(id);
}
Two details carry the weight. The re-check after acquiring the lock is what makes this correct — without it, every waiter rebuilds in turn once the holder releases, turning a simultaneous herd into a sequential one. And bounding the wait with tryLock rather than blocking indefinitely means a slow database degrades reads instead of stalling every thread that wants the key. The thundering herd problem page covers lease management, TTL jitter and the other mitigations in full.
Two further options are worth knowing. Swapping RMapCache for RLocalCachedMap adds a near cache — a local copy on each application instance, invalidated across the cluster automatically — which removes the network hop on hits. And if you use Spring Cache, note that @Cacheable is closer to read-through than to cache-aside: your annotated method becomes the loader the cache invokes on a miss, so the check-and-populate sequence above never appears in your code at all.
For working code covering all four patterns end to end, see Java caching strategies with Valkey and Redis.
When to use cache-aside
Cache-aside is the right default for read-heavy workloads with uneven access patterns, where a small subset of keys serves most requests and brief staleness is acceptable. It is resilient — if the cache is unavailable the application still reads from the database — and it caches only what someone actually asked for.
It fits less well when writes dominate, when the cache and database must never diverge, or when the same entity is read from dozens of call sites and the miss-handling block starts drifting between them. The first two point toward write-through, the third toward read-through.
Frequently asked questions
What is cache-aside?
Cache-aside is a caching pattern in which the application manages the cache itself. It checks the cache before reading the database, populates the cache after a miss, and invalidates the entry after a write. The cache never queries the database directly.
Why is cache-aside also called lazy loading?
Because entries are only created in response to a real request. Nothing is loaded in advance; a key enters the cache the first time someone asks for it and misses. The cache therefore holds only data that has actually been read.
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 Redis cache-aside?
Not by itself. Redis and Valkey are key-value stores with no built-in pattern; a GET on a missing key simply returns nothing. Cache-aside is implemented in your application code, which is why it works with every Redis client.
Should you update or delete the cache entry on a write?
Delete it. A delete is idempotent and forces the next read to repopulate from the system of record. Overwriting risks seeding the cache with a value that a concurrent transaction has already replaced, and that wrong value then persists until it expires.
What are the drawbacks of cache-aside?
The miss-handling block is repeated at every read path and can drift between them, there is a race window between a database write and the cache invalidation, and a popular key expiring under load can send every concurrent reader to the database at once. A TTL, invalidation on write, and a lock around the repopulation step address these.