Java Caching Strategies with Valkey & Redis: Cache-Aside to Write-Behind
A cache is only as good as the pattern you put around it. The same Valkey or Redis instance can be a read accelerator, a write buffer, a source of stale bugs, or a quiet correctness hazard — and which one you get depends almost entirely on how your application reads from and writes to it.
This guide walks through the four caching patterns every Java backend engineer should know, when each one earns its place, and how to implement all of them with Redisson, the Valkey and Redis Java client whose strongest area is exactly this: advanced caching. By the end you'll be able to wire up cache-aside in Java, layer in a write-behind cache, add a near cache for sub-millisecond reads, keep stale data under control, and expose the whole thing through Spring's @Cacheable.
The Four Caching Strategies
The patterns differ along two axes: who is responsible for talking to the database (your code, or the cache layer) and when writes reach the database (immediately, or later).
1. Cache-Aside (Lazy Loading)
Cache-aside is the pattern most teams reach for first because it's the most explicit. Your application code orchestrates everything:
- On a read, check the cache.
- On a miss, load from the database, populate the cache, and return the value.
- On a write, update the database and then invalidate or update the cache entry.
The cache sits aside the main data flow — it never talks to the database itself. The upside is total control and a cache that only ever holds data someone actually asked for. The downside is boilerplate in every read path and a classic race window between the database write and the cache invalidation.
public Product getProduct(String id) {
RMap<String, Product> cache = redisson.getMap("products");
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;
}
2. Read-Through
Read-through keeps the same lazy-loading behavior but moves the "load from database on miss" logic into the cache layer. Your code reads from the cache and never sees the database directly; the cache invokes a loader function on a miss. The benefit is that the read path becomes a single line and the loading logic lives in exactly one place. The trade-off is that you give up some control to the cache's plumbing.
3. Write-Through
Write-through addresses the write side. Every write goes to the cache, and the cache synchronously writes through to the database before the call returns. Cache and database stay consistent at all times, which is the pattern's whole appeal. The cost is latency: every write now pays for two round trips (cache plus database) on the critical path.
4. Write-Behind (Write-Back)
Write-behind is the high-throughput cousin of write-through. Writes hit the cache and return immediately; the cache batches them and flushes to the database asynchronously after a short delay. This collapses many database writes into a few batched ones and takes the database entirely off the write critical path — which is why a write-behind cache can absorb bursts that would otherwise overwhelm the backing store.
The trade-off is durability and consistency. If the cache node dies before a flush, unwritten changes are lost, and for a window of time the database is behind the cache. Write-behind is a deliberate exchange of strict durability for throughput.
Cache-Aside vs Read-Through vs Write-Through vs Write-Behind: When to Use Each
| Pattern | Read path | Write path | Best for | Main risk |
|---|---|---|---|---|
| Cache-aside | App checks cache, loads DB on miss | App writes DB, then invalidates cache | General-purpose read-heavy workloads; full control | Stale entries from invalidation races |
| Read-through | Cache loads DB on miss | (paired with a write pattern) | Removing read boilerplate; centralized loading | Less control over load behavior |
| Write-through | Usually read-through | Synchronous DB write before return | Data where cache/DB must never diverge | Higher write latency |
| Write-behind | Usually read-through | Async batched DB write after a delay | Write-heavy, bursty workloads tolerant of small lag | Data loss on crash; temporary inconsistency |
A useful rule of thumb: start with cache-aside for reads, and only reach for read-through, write-through, or write-behind once a specific pressure point — boilerplate, consistency, or write throughput — forces the question. Mixing patterns is normal: read-through for loads paired with write-behind for flushes is a common, powerful combination.
Why Redisson on Valkey and Redis
Plain key-value GET/SET gets you cache-aside and not much else. Implementing read-through, write-through, and write-behind by hand means writing your own loaders, write batchers, retry logic, and invalidation messaging — a lot of subtle, easy-to-get-wrong code.
Redisson takes a different approach. Its distributed RMap family implements java.util.concurrent.ConcurrentMap, so a Valkey- or Redis-backed map looks like an ordinary Java map, and the caching patterns are configuration rather than custom code. The relevant building blocks:
-
RMap— a standard distributed map with strong consistency. -
RMapCache— adds per-entry TTL and max-idle expiration on top ofRMap. -
RLocalCachedMap— adds a client-side near cache in front of the distributed map, which Redisson reports can serve reads up to 45x faster by avoiding network round trips. -
Persistence hooks —
MapLoader(read-through) andMapWriter(write-through / write-behind) attach an external data source to any of the above.
It also speaks the standard Java caching frameworks — Spring Cache, JCache (JSR-107), and Hibernate second-level cache — so the same engine drops into existing applications. The examples below use the redis:// connection scheme, which works against both Valkey and Redis.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>4.6.0</version>
</dependency>
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
Implementing the Patterns with Redisson
Read-Through With a MapLoader
Attach a MapLoader and the map fills itself on a miss. Your read path becomes a plain map.get(id) — the loader handles the database call, turning cache-aside boilerplate into read-through behavior.
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 p = products.get("sku-123"); // loads from DB transparently on a miss
Write-Through (Synchronous)
Add a MapWriter and set WriteMode.WRITE_THROUGH. Now put and remove won't return until the change has been persisted to the external store, keeping cache and database in lockstep.
MapWriter<String, Product> writer = new MapWriter<>() {
@Override
public void write(Map<String, Product> added) {
productRepository.upsertAll(added); // persist before returning
}
@Override
public void delete(Collection<String> keys) {
productRepository.deleteAllById(keys);
}
};
MapOptions<String, Product> options = MapOptions.<String, Product>defaults()
.loader(loader) // read-through
.writer(writer)
.writeMode(MapOptions.WriteMode.WRITE_THROUGH);
RMap<String, Product> products = redisson.getMap("products", options);
products.put("sku-123", updated); // blocks until the DB write completes
Write-Behind (Asynchronous)
Switch the write mode to WRITE_BEHIND and the same MapWriter is now driven by a background flusher. Writes return immediately; Redisson accumulates them and flushes in batches. Two knobs control the behavior:
-
writeBehindDelay— how long to wait before flushing a batch (default 1000 ms). -
writeBehindBatchSize— how many entries per batch (default 50).
MapOptions<String, Product> options = MapOptions.<String, Product>defaults()
.loader(loader)
.writer(writer)
.writeMode(MapOptions.WriteMode.WRITE_BEHIND)
.writeBehindDelay(5000) // flush at most every 5 seconds
.writeBehindBatchSize(100); // up to 100 changes per batch
RMap<String, Product> products = redisson.getMap("products", options);
products.put("sku-123", updated); // returns immediately; DB write happens later
Tuning these two values is the whole game with a write-behind cache: a longer delay and larger batch coalesce more writes and protect the database harder, at the cost of a wider window where the database trails the cache. These persistence hooks work on RMap, RMapCache, RLocalCachedMap, and RLocalCachedMapCache alike.
Adding a Near Cache With RLocalCachedMap
For read-heavy data, the fastest read is the one that never crosses the network. RLocalCachedMap keeps a copy of hot entries inside the application process and only contacts Valkey or Redis to coordinate updates and invalidations across instances. All the persistence options above still apply, so you can run read-through plus write-behind and a near cache at once.
LocalCachedMapOptions<String, Product> options =
LocalCachedMapOptions.<String, Product>defaults()
.cacheSize(10_000) // local entries to keep
.evictionPolicy(LocalCachedMapOptions.EvictionPolicy.LRU)
.syncStrategy(LocalCachedMapOptions.SyncStrategy.INVALIDATE)
.timeToLive(Duration.ofMinutes(5))
.loader(loader)
.writer(writer)
.writeMode(MapOptions.WriteMode.WRITE_BEHIND);
RLocalCachedMap<String, Product> products =
redisson.getLocalCachedMap("products", options);
Product p = products.get("sku-123"); // served from the local process on a hit
A few notes that matter in production. Treat each named local cache as a singleton per Redisson instance and use the same options object across instances sharing a name — local caches with the same name subscribe to a shared pub/sub channel to exchange update and invalidate events. In open-source Redisson, getLocalCachedMap() and getMapCache() are available out of the box; the clustered and LocalCachedMapCache variants are part of Redisson PRO.
Avoiding Stale Data
A near cache is a second copy of your data, and second copies go stale. Redisson gives you several levers to keep them honest.
Synchronization strategy decides what happens to every other instance's local copy when one instance changes an entry:
-
INVALIDATE(the default) — evict the entry everywhere, so the next read reloads a fresh value. Low traffic, slightly slower next read. -
UPDATE— push the new value to every instance immediately. Faster subsequent reads, more invalidation traffic. -
NONE— no cross-instance messaging; only safe for data that never changes or where staleness is acceptable.
Time to live and max idle put a hard ceiling on staleness regardless of messaging. RMapCache and the local-cache options both support per-entry timeToLive and maxIdle, so even a missed invalidation event self-heals when the entry expires. A short TTL is the simplest backstop against subtle invalidation bugs.
Reconnection strategy handles the gap when an instance loses its connection and misses invalidation messages. CLEAR drops the whole local cache after a disconnect; LOAD replays a short window of invalidations so the cache can be repaired without a full flush. The default is no special handling, which means a disconnected node can serve stale data until something else evicts it — usually not what you want for mutable data.
The remaining stale-data trap is pattern-level, not Redisson-level: in plain cache-aside, the window between a database write and the cache invalidation is a real race. Closing it means either invalidating before the write commits, using short TTLs, or moving the write coordination into the cache with write-through or write-behind so there is no second step to forget.
Spring @Cacheable Integration
If you're on Spring Boot, you rarely need to touch RMap directly. Redisson ships a CacheManager that plugs into Spring's caching abstraction, so the standard caching annotations — @Cacheable, @CachePut, @CacheEvict — run on Valkey or Redis with no change to your service code. This is the most common entry point for adding caching strategies in Spring Boot.
@Configuration
@EnableCaching
public class RedissonCacheConfig { // not named CacheConfig, to avoid shadowing Redisson's type
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
@Bean
public CacheManager cacheManager(RedissonClient redissonClient) {
// CacheConfig here is org.redisson.spring.cache.CacheConfig
Map<String, CacheConfig> config = new HashMap<>();
// "products" cache: ttl = 24 min, maxIdleTime = 12 min
config.put("products", new CacheConfig(24 * 60 * 1000, 12 * 60 * 1000));
return new RedissonSpringCacheManager(redissonClient, config);
}
}
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getProduct(String id) {
return productRepository.findById(id); // runs only on a cache miss
}
@CachePut(value = "products", key = "#product.id")
public Product save(Product product) {
return productRepository.save(product); // updates DB and cache
}
@CacheEvict(value = "products", key = "#id")
public void delete(String id) {
productRepository.deleteById(id);
}
}
Annotated this way, @Cacheable is cache-aside, handled for you by Spring and Redisson together. To add a near cache for read speed, swap in RedissonSpringLocalCachedCacheManager with LocalCachedCacheConfig — same annotations, same service code, with hot reads served locally.
Prefer the vendor-neutral standard? Redisson is a fully certified JCache (JSR-107) provider — it passes the entire Technology Compatibility Kit — so you can use javax.cache APIs (or Spring's JCache support via spring.cache.type=jcache) and keep your caching code portable across providers.
Demo: Read-Through Reads, Write-Behind Flushes
Here's a compact end-to-end setup that combines the pieces: read-through loading, a write-behind flush to the database, a near cache for speed, and a short TTL as a staleness backstop. It's the configuration many production services converge on.
public class ProductCache {
private final RLocalCachedMap<String, Product> products;
public ProductCache(RedissonClient redisson, ProductRepository repo) {
MapLoader<String, Product> loader = new MapLoader<>() {
public Product load(String key) { return repo.findById(key); }
public Iterable<String> loadAllKeys() { return repo.findAllIds(); }
};
MapWriter<String, Product> writer = new MapWriter<>() {
public void write(Map<String, Product> changes) { repo.upsertAll(changes); }
public void delete(Collection<String> keys) { repo.deleteAllById(keys); }
};
LocalCachedMapOptions<String, Product> options =
LocalCachedMapOptions.<String, Product>defaults()
.cacheSize(10_000)
.evictionPolicy(LocalCachedMapOptions.EvictionPolicy.LRU)
.syncStrategy(LocalCachedMapOptions.SyncStrategy.INVALIDATE)
.timeToLive(Duration.ofMinutes(10))
.reconnectionStrategy(LocalCachedMapOptions.ReconnectionStrategy.LOAD)
.loader(loader)
.writer(writer)
.writeMode(MapOptions.WriteMode.WRITE_BEHIND)
.writeBehindDelay(3000)
.writeBehindBatchSize(100);
this.products = redisson.getLocalCachedMap("products", options);
}
// Reads: local hit, else Valkey/Redis, else DB via the loader.
public Product get(String id) {
return products.get(id);
}
// Writes: returns immediately; the DB write is batched and flushed later.
public void save(Product p) {
products.fastPut(p.id(), p);
}
public void remove(String id) {
products.fastRemove(id);
}
}
Tracing a request through this code shows the patterns working together. A get first checks the in-process near cache; on a local miss it asks Valkey or Redis; on a full miss the MapLoader reads the database and the result is cached at both layers (read-through). A save writes the near cache and returns instantly — the MapWriter flushes to the database in a batch a few seconds later (write-behind), and the INVALIDATE strategy tells the other instances to drop their stale copies. The 10-minute TTL guarantees nothing lives in the cache indefinitely, and ReconnectionStrategy.LOAD repairs the local cache after a network blip without a full flush.
Valkey & Redis Caching Patterns: The Bottom Line
Caching patterns aren't a ladder you climb from worst to best — they're a menu you pick from based on your read/write mix and how much staleness or write latency you can tolerate. Cache-aside is the sensible default. Read-through removes the boilerplate. Write-through buys consistency at the price of latency, and write-behind buys throughput at the price of strict durability. Redisson's value is that on Valkey and Redis these are configuration choices on the same RMap family rather than four hand-rolled implementations — and a near cache, TTL-based staleness control, and Spring @Cacheable integration come along with it.