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 key product:1 with a 10-minute TTL.
  • Subsequent calls within 10 minutes return from Redis, never touching the database.
  • updatePrice modifies 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 Customer profiles 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 @Version and 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:

  • updatePrice updates the record in Redis and adds the id to the products:dirty set. The request completes in milliseconds.
  • Every 5 seconds, flush() pops each id with SPOP and 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 id goes 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 everysec or always, 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-policy to noeviction or volatile-*; otherwise Redis might evict unflushed data.
  • Multiple instances. Thanks to SPOP being atomic, two instances won’t claim the same id. However, if an instance dies right after SPOP and before save, that change is stuck in Redis until the next update. For more certainty, use Redis Streams with consumer groups and XACK.
  • 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, Order entities, payments via PaymentService, 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 PX so only one request reloads.
  • Cache penetration. Requests repeatedly ask for an id that 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.

rediscachespring-bootjavaperformance