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, how to warm the cache after a cold start, 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.

The two stores in that loop are usually a relational database and an in-memory one. Whether you need both, or whether the database can hold the cache itself, is a separate question covered in Redis vs. Postgres.

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.

The application orchestrates both the cache and the database the cache never queries the database itself Cache Valkey / Redis passive key-value store Application owns the miss path check → load → populate Database system of record 1. get(key) 2. hit → value 3. miss → query 4. record 5. set(key, value, ttl) On a write: update the database, then delete the cached entry the gap between those two steps is where stale data appears

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 — not for an instant, as with a single expiring key, but continuously until every hot key has been requested at least once. Lazy loading guarantees this, because the pattern has no mechanism for filling the cache before somebody asks. If the database cannot absorb that window, either stage the restart so only part of the fleet is cold at any moment, or warm the critical keys on startup.

Choosing a TTL

If the cache sits behind Spring's @Cacheable rather than being called directly, TTL is set on the cache provider instead of in your own code — our Spring Boot Redis cache guide covers the per-cache and per-entry options.

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.

Warming the cache after a cold start

Cache warming is the practice of populating entries before real traffic asks for them, and it is the only direct answer to the cold start described above. Three moments create a cold cache: a deploy or rolling restart, a failover to a replica that was never serving reads, and a deliberate flush. In each case the cache is empty while request volume is unchanged, so every miss lands on the database at once.

Warming is not the same as prefetching. Prefetching anticipates the next request while the application is running; warming is a bulk load that happens before the instance serves anything. It is also not an attempt to rebuild the whole working set. The reason cache-aside suits uneven access patterns is that a small subset of keys serves most requests, and that same skew is what makes warming cheap: load the head of the distribution and the miss rate collapses, even though most keys are still absent. Loading everything replaces a slow warm-up with a slow deploy, and fires exactly the read burst against the database that warming was supposed to prevent.

The bulk-write trap

The obvious API is the wrong one. RMapCache will take an entire map in a single call:

RMapCache<String, Product> cache = redisson.getMapCache("products");

// convenient, and a scheduled outage: every entry expires in the same millisecond
cache.putAll(productRepository.findTopSellers(500), 10, TimeUnit.MINUTES);

That signature applies one identical TTL to the whole batch. Ten minutes after each deploy, all five hundred warmed keys expire together and every concurrent reader misses simultaneously — a textbook thundering herd, now on a predictable schedule. A naive warm-up job does not prevent the stampede; it manufactures one, and the synchronized-TTL trigger it creates produces a far larger herd than the single hot key most teams worry about.

The fix is a per-entry TTL with jitter, pipelined through an RBatch so that spreading the expiry does not cost one round trip per key:

public void warmOnStartup() {
    Map<String, Product> hot = productRepository.findTopSellers(500);

    RBatch batch = redisson.createBatch();
    RMapCacheAsync<String, Product> cache = batch.getMapCache("products");

    hot.forEach((id, product) -> {
        // 10 minutes plus up to 5 of jitter, so the warmed set expires
        // gradually instead of all at once
        long ttl = 600 + ThreadLocalRandom.current().nextLong(300);
        cache.fastPutAsync(id, product, ttl, TimeUnit.SECONDS);
    });

    batch.execute();   // one network round trip for all 500 writes
}

Two operational details decide whether this helps. The job has to complete before the instance accepts traffic — a Spring ApplicationRunner or a readiness probe that stays unready until warming finishes, not a background thread racing the load balancer. And because the cache is distributed, only one instance needs to do the work: twelve instances warming the same five hundred keys on a rolling deploy is twelve times the database load for one cache's worth of benefit. Guard the job with an RLock, or check whether the keys are already present before loading them. This is the point where warming a shared cache differs sharply from warming a near cache, where every instance genuinely does hold its own copy and must fill it independently.

Finally, warming is worth building only if the cold window is actually dangerous. Measure the database under a cold cache first. If it survives, a staged restart costs nothing and achieves most of the same result, and the warm-up job is complexity that can itself fail, go stale, or — as above — become the incident.

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. A worked read-through example against a managed NoSQL store is in Redis vs. DynamoDB.

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 is cache warming?

Cache warming is loading entries into the cache before real traffic requests them, so that the first users after a deploy, a failover or a flush get hits instead of misses. It is the standard remedy for the cold start that lazy loading creates, and it usually targets only the small set of hot keys that serves most requests rather than the entire working set.

How do you warm a Redis cache in Java?

Query the hot keys from the system of record at startup and write them into the cache before the instance accepts traffic — in Spring, from an ApplicationRunner or behind a readiness probe. With Redisson, pipeline the writes through an RBatch so the whole batch costs one round trip, and give each entry its own jittered TTL with fastPutAsync rather than using putAll with a single shared TTL. Because the cache is shared, run the job on one instance rather than all of them.

Should you warm the cache after every deploy?

Only if the database cannot absorb the cold read burst — measure it before building anything. A staged or rolling restart, which leaves most of the fleet warm at any moment, costs nothing and often removes the need. When warming is genuinely required, jitter the TTLs: writing every warmed key with the same expiry turns the cold start into a scheduled thundering herd that fires one TTL after each deploy.

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.

Similar terms