Skip to content

Caffeine refreshAfterWrite vs expireAfterWrite: Why You Often Want Both

Published on
Reading time
5 mins read

A local Caffeine cache in front of a slow downstream is one of the cheapest latency wins in a Java service, on its own or as the first tier in front of Redis. How it behaves under load often comes down to one line: expireAfterWrite or refreshAfterWrite. They sound interchangeable. They aren't.

expireAfterWrite: the entry is gone, and someone waits

expireAfterWrite(Duration.ofMinutes(10)) makes an entry invisible ten minutes after it was created or last replaced. The next get for that key finds nothing and loads it again on the caller's thread.

Caffeine loads a key only once at a time: while one thread loads it, other LoadingCache.get calls for that key wait for the result. (Hand-rolled getIfPresent plus put loses even that, and every concurrent miss goes downstream.) So the cost isn't a per-key stampede inside one cache. It's that requests for the key block until the reload finishes: a periodic latency spike on hot keys. Across all instances, and across keys that were loaded together after a deploy and expire together, those waits become bursts of synchronous reloads.

refreshAfterWrite: serve the old value, reload in the background

refreshAfterWrite(Duration.ofMinutes(1)) makes an entry eligible for refresh one minute after it was written. Nothing runs on a timer. The first read after that point starts an asynchronous reload and returns without waiting for it; readers keep getting the old value until the new one replaces it. Only one refresh per key is in flight at a time, and reloads run on the cache's executor (ForkJoinPool.commonPool() unless you set Caffeine.executor(...)).

Two consequences follow:

  • Refresh needs a loader. It works on a LoadingCache or AsyncLoadingCache; calling build() without a CacheLoader throws IllegalStateException: refreshAfterWrite requires a LoadingCache.
  • The refresh interval doesn't bound staleness. A key read once an hour hands out an hour-old value on that read, and only then reloads.

Using both

ExchangeRateCacheConfig.java
@Bean
LoadingCache<String, BigDecimal> exchangeRates(RatesClient client, MeterRegistry registry) {
    LoadingCache<String, BigDecimal> cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .refreshAfterWrite(Duration.ofMinutes(1))  // keys in use: reload off the request path
            .expireAfterWrite(Duration.ofMinutes(10))  // any key: never served older than this
            .recordStats()
            .build(client::fetchRate);
    return CaffeineCacheMetrics.monitor(registry, cache, "exchangeRates");
}

With refresh shorter than expire, the two settings split the job:

  • refreshAfterWrite is how stale a key that's being read may get before a new value is fetched in the background.
  • expireAfterWrite is the hard limit. A value not refreshed within ten minutes, because nobody read it or its refreshes kept failing, is dropped, and the next reader waits for a fresh load instead of getting a very old value.

A successful refresh resets the write time, so a hot key keeps refreshing and never reaches its expiry. Caffeine won't stop you from setting refresh equal to or longer than expire, but then entries expire before a refresh can happen and you're back to blocking loads.

There's a cost: a one-minute refresh reloads a hot key about once a minute per instance, ten times the downstream traffic of the ten-minute expiry alone. Pick the refresh interval for the staleness you can accept, not the smallest number that looks safe.

When a refresh fails

If the loader throws during a refresh, Caffeine keeps the old value, logs the exception at WARNING through System.Logger and swallows it. Callers see nothing. The entry keeps its old write time, so it stays eligible and the next read starts another attempt, still one at a time per key.

That's usually the right behavior for a short downstream blip, but it has two sharp edges:

  • Hit rate looks healthy. Readers keep getting hits while the values age. A failed refresh is recorded as a load failure in the cache stats, so alert on that.
  • expireAfterWrite decides how long you hide an outage. Once the entry expires, the next get loads synchronously, and if the downstream is still failing, the exception reaches the caller.

Stats and metrics

recordStats() is off by default. Micrometer's CaffeineCacheMetrics reads from those stats and publishes meters such as cache.gets (tagged result hit or miss), cache.evictions, cache.size and, for loading caches, cache.load (tagged result success or failure) and cache.load.duration. Bind a cache that isn't recording stats and Micrometer logs a warning and has almost nothing to publish.

Spring's CaffeineCacheManager

With Spring Boot's cache abstraction, the same settings go into a spec string:

spring.cache.cache-names=exchangeRates
spring.cache.caffeine.spec=maximumSize=10000,refreshAfterWrite=1m,expireAfterWrite=10m,recordStats

recordStats in the spec is what makes Boot's cache metrics work. refreshAfterWrite is harder: the manager then needs a CacheLoader, or creating the cache fails with the same IllegalStateException. Boot wires in a CacheLoader<Object, Object> bean if you define one, but every cache the manager builds shares it. Spring's CaffeineCache also reads through LoadingCache.get, so on a plain @Cacheable miss the loader, not your annotated method, produces the value. For a single cache that needs refresh, I find a hand-built LoadingCache like the one above clearer.

Takeaways

  • expireAfterWrite alone is simple, but readers of an expired key wait for the reload.
  • refreshAfterWrite reloads on the first read after the interval and serves the old value meanwhile. It needs a loading cache.
  • Use both, refresh shorter than expire: refresh keeps hot keys fresh, expire caps staleness.
  • Failed refreshes are logged and swallowed. Watch load failures, not just hit rate.
  • In Spring, a spec with refreshAfterWrite needs a CacheLoader shared by every cache the manager creates.