Caching: Cache-Aside, Write-Through, and TTL

1. Overview

Cache reduces database load and shortens response time. In return, you must accept that your data can become stale. This article presents two common cache read/write strategies: cache-aside and write-through. It also explains how to use TTL (time-to-live) to limit how stale your data can get.

After reading, you’ll be able to implement both strategies using plain Java, switch to using Spring Cache annotations, configure TTL for Caffeine and Redis, and avoid a few common mistakes.

2. Basic Concepts

Before diving into the code, let’s agree on a few terms:

  • Cache hit: the data exists in the cache and is returned immediately.
  • Cache miss: the cache doesn’t have the data, so you must fetch it from the source (database, another service).
  • Stale data: the data in the cache differs from what’s in the database.
  • TTL: the lifespan of an entry. Once TTL expires, the entry is no longer considered valid.

Every caching strategy boils down to one question: when data changes, who is responsible for updating the cache, and when should it happen?

Let’s use a simple domain throughout the article:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. A Homemade TTL Cache

To understand the mechanism clearly, let’s build a small cache with TTL from scratch. Time is obtained via a Supplier<Instant>, which lets tests “skip ahead” the clock.

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

How this class works:

  • put stores the value along with an expiration time: expiresAt = now + ttl.
  • get checks the expiration. If the entry is past expiration, it’s removed and the method returns Optional.empty(), which is a cache miss.
  • store.remove(key, entry) only removes the exact entry we just read. This way, if another thread just called put with a new value, that new value won’t be accidentally removed.

This class is enough for demonstration purposes. In production, use Caffeine or Redis (see section 6).

4. Cache-Aside

4.1. The Idea

With cache-aside (also called lazy loading), the application manages the cache itself:

  1. Read: check the cache first. On miss, read the database, store the result in cache, and return it.
  2. Write: update the database first, then delete the entry from the cache. The next read will reload the fresh data.
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // cache hit
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // load into cache
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // delete after database write is done
        return saved;
    }
}

Explanation:

  • findById only calls the repository on a cache miss. If the product doesn’t exist, we don’t cache anything.
  • updatePrice writes the database first, then evicts the cache. This order is important; see section 8 for why.
  • We choose to delete the entry rather than overwrite it with a new value. This is simpler, since cache loading happens from a single path: the read route.

4.2. When to Use

Cache-aside is suitable when:

  • The read-to-write ratio is very high, such as product catalogs or user profiles.
  • You only want to cache data that is actually accessed.
  • The system can still run if the cache goes down. Requests then go straight to the database: slower, but not broken.

The downside: the first request after each miss is slow. Also, if another process writes directly to the database without deleting the cache, the data stays stale until TTL expires.

5. Write-Through

5.1. The Idea

With write-through, all writes go through the cache layer. The layer writes synchronously to the database, then updates the cache with the new value. When the write returns, cache and database are in sync.

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. write DB (synchronously)
        cache.put(saved.id(), saved);             // 2. update cache
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

Differences from cache-aside:

  • save overwrites the cache with the newly saved value, instead of deleting it. A read right after is a cache hit.
  • We only update the cache if repository.save succeeds. If the database throws an exception, the cache is untouched, so there’s no stale data that never existed.
  • findById still needs a fallback clause, because an entry might have expired or never been written via save.

5.2. When to Use

Write-through fits scenarios where data written is immediately read again, such as shopping carts or user settings. The trade-off:

  • Each write is slightly slower because you write to two places.
  • The cache might contain a lot of data nobody reads. Combine it with TTL and size limits.

Note: Don’t confuse write-through with write-behind (write-back). Write-behind updates the cache first, then writes to the database asynchronously. Writes are faster, but if the cache node dies before flushing, you lose data. Only use write-behind if you can accept that risk.

6. Using Spring Cache

Writing by hand helps you understand the mechanism, but in real projects you should use Spring Cache with Caffeine (local cache) or Redis (distributed cache). Add the dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Enable caching with @EnableCaching:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. Cache-Aside with @Cacheable and @CacheEvict

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable is cache-aside in declarative form. Spring checks the cache first; on miss, it calls the method and stores the result.
  • For Optional return types, Spring caches the value inside. unless = "#result == null" prevents caching the not-found case.
  • @CacheEvict by default runs after the method returns successfully. This is the correct order: “write DB, then delete cache.”

6.2. Write-Through with @CachePut

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePut always runs the method, then writes the return value to the cache. This is equivalent to WriteThroughProductStore.save in section 5.

Note: Annotation caching works via a proxy. If you call this.findById(...) from another method in the same class, caching is bypassed entirely. Call through another bean instead.

7. Configuring TTL

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m: an entry expires 10 minutes after its last write.
  • maximumSize=10000: when the limit is exceeded, Caffeine evicts the least-used entries. Always set a limit to prevent the cache from consuming all heap memory.

7.2. Redis

Add spring-boot-starter-data-redis, then configure:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis automatically deletes keys when they expire. This cache is shared across multiple instances of your application.

7.3. How Long Should TTL Be?

There’s no universal number. Ask yourself: how stale can the data become?

Exchange rates, inventory    : seconds to 1 minute
Product prices, catalogs     : 5 to 30 minutes
Configuration, provinces/cities : hours to days

Short TTL gives fresher data but lower hit rates. Long TTL is the opposite. Even with active eviction, always set a TTL as a safety net for missed evictions.

7.4. Add Jitter to Avoid Thundering Herd

Suppose you load 10,000 products at once with a 10-minute TTL. They’ll all expire at the same time, and the database receives a burst of requests. This is called cache stampede. The fix is to add a random amount to the TTL:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

With Spring + Caffeine, you can add @Cacheable(..., sync = true). When multiple threads miss the same key, only one calls the database; the others wait for the result.

8. Writing Tests

Let’s use a mock repository that counts database accesses, and a clock we can control:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Conical Hat", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache: the second read doesn’t touch the database.
  • entryExpiresAfterTtl: after advancing the clock past 10 minutes, cache misses and the database is called again.
  • updateEvictsStaleValue: after updating, the reader sees the new price, not the stale one from cache.

9. Common Mistakes

Deleting the cache before writing to the database. Consider this race: thread A deletes the cache. Thread B reads, gets a miss, fetches the old value from the database, and writes it to cache. Then thread A finally writes the new value to the database. The result is a cache holding the old value until TTL expires. Always write to the database first, then delete the cache.

Caching inside an uncommitted transaction. If you @CachePut inside a @Transactional method and the transaction rolls back, the cache contains data that no longer exists in the database. Put the cache operation outside the transaction, or use TransactionAwareCacheManagerProxy.

Local cache on multiple instances. Caffeine lives inside each JVM instance. @CacheEvict on instance A doesn’t delete the cache on instance B. When running multiple instances and need consistency, use Redis or accept staleness via short TTL.

Caching mutable objects. Caffeine returns the exact reference stored in the cache. If you mutate the object, you’ve also mutated the cache. Use record or immutable objects like in the examples.

Note: Don’t cache when data changes frequently and requires absolute consistency, such as account balance or available seats during checkout. In these cases, reading directly from the database with appropriate locks is safer.

10. Conclusion

This article compared two main caching strategies:

  • Cache-Aside: the application loads cache on miss and deletes on write. This is the simplest, most fault-tolerant, and suits most read-heavy workloads.
  • Write-Through: all writes synchronously update both database and cache, ensuring fresh data immediately after a write.

Whichever strategy you choose, always use TTL as a safety net, limit cache size, and add jitter to avoid stampede. Start with @Cacheable + Caffeine. When you scale to multiple instances, move to Redis, and always test that the cache actually reduces database calls.

system-designcachingspring-bootcaffeineredis