Redis: Choosing Cache-Aside, Write-Through, or Write-Behind
1. Overview
Adding Redis in front of the database is the fastest way to reduce latency and load on your system. The hard part is elsewhere: you must decide how to write data so that cache and database stay in sync. There are three popular strategies: cache-aside, write-through, and write-behind.
This post implements all three with Spring Boot 3 and Redis. Then it compares them by consistency, performance, and risk of data loss. After reading, you’ll know which strategy to pick for each type of data.
2. Setup
2.1. Dependencies
We’ll use Spring Data Redis, Spring Data JPA with H2 as the sample database, and Testcontainers for testing:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Configure Redis connection for local development:
spring.data.redis.host=localhost
spring.data.redis.port=6379
2.2. Entity, Repository, and RedisTemplate
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
private String name;
private BigDecimal price;
protected Product() {
}
public Product(String name, BigDecimal price) {
this.name = name;
this.price = price;
}
// getters and setters
}
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Spring Boot provides StringRedisTemplate out of the box. To store Product as JSON, we declare an additional RedisTemplate:
@Configuration
@EnableScheduling
public class RedisConfig {
@Bean
RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
var template = new RedisTemplate<String, Product>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
return template;
}
}
Keys have the form product:{id}, values are JSON-encoded Product objects. The @EnableScheduling annotation is used in the write-behind section.
All three strategies need to run code after transaction commit. We wrap this in a helper:
public final class AfterCommit {
public static void run(Runnable action) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
}
If we’re in a transaction, action only runs if commit succeeds. If the transaction rolls back, the cache is not touched.
3. Cache-Aside (Lazy Loading)
In cache-aside, the application manages the cache itself:
- Read: check Redis first. On miss, read from the database and write the result to Redis with a TTL.
- Write: update the database, then delete the key from Redis.
@Service
public class ProductCacheAsideService {
private static final Duration TTL = Duration.ofMinutes(10);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductCacheAsideService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
public Optional<Product> findById(Long id) {
Product cached = redis.opsForValue().get(key(id));
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
return fromDb;
}
@Transactional
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(newPrice);
AfterCommit.run(() -> redis.delete(key(id)));
return product;
}
static String key(Long id) {
return "product:" + id;
}
}
The code’s behavior:
- The first call to
findById(1L)reads the database and creates the keyproduct:1with a 10-minute TTL. - Subsequent calls within 10 minutes return from Redis, never touching the database.
updatePricemodifies the entity; JPA’s dirty checking writes it to the database on commit, then deletes the key.- The next read reloads the new value.
Verify with redis-cli:
redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Mechanical Keyboard\",\"price\":1200000.00}"
(integer) 587
Note: On write, delete the key instead of setting a new value. Suppose two requests update at the same time. If both set values, the write order to Redis may differ from the commit order to the database, and the cache keeps stale data forever. Deleting the key ensures the next read always gets the latest version. Also, delete after commit. If you delete before commit, a read request slipping in between may reload stale data into the cache.
Even when done correctly, cache-aside has a small race window: a read request fetches stale data from the database and writes it to the cache after the key was just deleted. TTL is the final safety net for this case. Never cache without setting a TTL.
For simple data, you can use Spring Cache’s @Cacheable and @CacheEvict to achieve the same effect with less code. They are fundamentally cache-aside under the hood.
4. Write-Through
In write-through, each write updates both database and cache synchronously. The request only returns after both complete. Reads work like cache-aside, but the hit rate is higher because recently written data is already in Redis.
@Service
public class ProductWriteThroughService {
private static final Duration TTL = Duration.ofMinutes(30);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductWriteThroughService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
@Transactional
public Product save(Product product) {
Product saved = repository.saveAndFlush(product);
AfterCommit.run(() -> redis.opsForValue()
.set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
return saved;
}
}
The saveAndFlush call writes to the database immediately to get the id and catch constraint violations early. After commit, the record is pushed to Redis. When a newly created product is read again, it’s a cache hit on the first try.
Advantages of write-through:
- Cache is always “warm” with freshly written data, no first-read miss.
- Suitable for data that’s read immediately after writing, like
Customerprofiles or shopping carts.
Disadvantages:
- Each write incurs an extra round-trip to Redis, increasing write latency.
- The cache fills with records that no one reads.
- TTL helps evict them, but still wastes memory.
- Race conditions still occur when multiple writers set values, as discussed in section 3.
- For data updated concurrently, add a version field using
@Versionand only overwrite if the version is newer, or fall back to deleting the key.
Note: If Redis fails after the database commits, the cache will be missing the latest version. Catch the exception and delete the key as a fallback. Don’t let the request fail when the database write succeeds.
5. Write-Behind (Write-Back)
In write-behind, the application writes only to Redis and returns immediately. A background process batches changes and writes them to the database. The database is no longer on the request path, so writes are very fast. In exchange, Redis temporarily becomes the system of record.
@Service
public class ProductWriteBehindService {
private static final String DIRTY_SET = "products:dirty";
private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
private final StringRedisTemplate stringRedis;
public ProductWriteBehindService(ProductRepository repository,
RedisTemplate<String, Product> redis,
StringRedisTemplate stringRedis) {
this.repository = repository;
this.redis = redis;
this.stringRedis = stringRedis;
}
public void updatePrice(Long id, BigDecimal newPrice) {
String key = ProductCacheAsideService.key(id);
Product product = Optional.ofNullable(redis.opsForValue().get(key))
.or(() -> repository.findById(id))
.orElseThrow();
product.setPrice(newPrice);
redis.opsForValue().set(key, product); // no TTL
stringRedis.opsForSet().add(DIRTY_SET, id.toString());
}
@Scheduled(fixedDelay = 5000)
public void flush() {
String id;
while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
if (product == null) {
continue;
}
try {
repository.save(product);
} catch (RuntimeException ex) {
log.warn("Flush failed for product {}, will retry", id, ex);
stringRedis.opsForSet().add(DIRTY_SET, id);
break;
}
}
}
}
The flow is:
updatePriceupdates the record in Redis and adds theidto theproducts:dirtyset. The request completes in milliseconds.- Every 5 seconds,
flush()pops eachidwithSPOPand writes the corresponding record to the database. - If a product is updated 100 times in 5 seconds, the database receives one write. This is the biggest win of write-behind.
- When a write fails, the
idgoes back into the set for retry next time.
This is the riskiest pattern of the three. Several points require careful handling:
- Data loss if Redis crashes. Enable AOF with
appendfsync everysecoralways, and run Redis with a replica. Even so, you may lose up to 1 second of data. - Eviction. Dirty keys have no TTL. Set
maxmemory-policytonoevictionorvolatile-*; otherwise Redis might evict unflushed data. - Multiple instances. Thanks to
SPOPbeing atomic, two instances won’t claim the sameid. However, if an instance dies right afterSPOPand beforesave, that change is stuck in Redis until the next update. For more certainty, use Redis Streams with consumer groups andXACK. - Queries against the database see stale data. Reports, other jobs, or other services reading the database directly will see data a few seconds behind.
Note: Don’t use write-behind for account balances,
Orderentities, payments viaPaymentService, or any data where losing a single record is a disaster. It works well for page views, likes, reading position, last-seen: data that’s written very frequently and can tolerate minor loss.
6. Testing Cache-Aside Behavior
The test below uses a real Redis running in Testcontainers. @ServiceConnection auto-configures the host and port for Spring Boot:
@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
ProductCacheAsideService service;
@Autowired
ProductRepository repository;
@Autowired
RedisTemplate<String, Product> redis;
@Test
void shouldCacheOnReadAndEvictAfterUpdate() {
Product saved = repository.save(new Product("Mechanical Keyboard", new BigDecimal("1200000")));
String key = ProductCacheAsideService.key(saved.getId());
service.findById(saved.getId());
assertThat(redis.hasKey(key)).isTrue();
service.updatePrice(saved.getId(), new BigDecimal("990000"));
assertThat(redis.hasKey(key)).isFalse();
Product reloaded = service.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
assertThat(redis.hasKey(key)).isTrue();
}
}
The test verifies the full lifecycle of the key: created on read, deleted after update, then reloaded with the new value. The test class is not annotated with @Transactional. If it were, the test transaction would roll back, the afterCommit callback would never run, and the second assertion would fail.
7. Comparison and How to Choose
| Criterion | Cache-Aside | Write-Through | Write-Behind |
|---|---|---|---|
| First-read latency | High (miss) | Low | Low |
| Write latency | Low | Medium (write to two places) | Very low |
| Consistency with DB | Good (TTL as safety net) | Good | Eventual, lagged by seconds |
| Data loss risk | None | None | Yes |
| Complexity | Low | Medium | High |
| Reduce write load on DB | None | None | Yes (batching) |
Some guidance on choosing:
- Start with cache-aside by default. It’s simple, fault-tolerant (if Redis dies, you can still read from the database), and sufficient for most read-heavy data like product catalogs or configs.
- Choose write-through when data is often read right after writing and you want to avoid first-read misses. In practice, many systems blend both: write like write-through, read like cache-aside.
- Only choose write-behind when the database is truly a write bottleneck and data loss is acceptable. Without metrics proving the database is overloaded, don’t pay the complexity cost.
- You don’t have to pick one strategy for the whole system. Each data type can use a different strategy.
Common mistakes with all three strategies:
- Cache stampede. A hot key expires, thousands of requests miss simultaneously and hammer the database. Mitigate with randomized jitter in TTL, or lock with
SET key value NX PXso only one request reloads. - Cache penetration. Requests repeatedly ask for an
idthat doesn’t exist, always missing. Cache the null result with a short TTL. - Changing class structure without clearing the cache. Old JSON won’t deserialize into the new class. Add a version to the key, like
product:v2:{id}.
8. Conclusion
This post implemented three caching strategies with Redis and Spring Boot 3. Cache-aside loads data on demand and deletes the key after commit. Write-through syncs both places on every write. Write-behind trades durability for fast writes.
In short: start with cache-aside and always set a TTL. Graduate to write-through when you need a warm cache. Use write-behind only for lossy data, with AOF, a suitable eviction policy, and clear retry logic.