Quarkus Cache Invalidation Rules You Need Before Production
A practical look at stale data risk, failed writes, and when a service should favor cached reads over forced invalidation.
Cache annotations look harmless until a failed write leaves old data in the one place nobody is staring at: the cache. The logs show an error. The database, token store, or remote system may already have changed. The next read still returns the value from five minutes ago.
That is not a Quarkus bug. It is the cache contract doing exactly what it says.
@CacheInvalidateAll runs only after the annotated method completes successfully. If the method throws, invalidation is skipped. That protects a cache from being emptied by a write that rolled back. It also means a method that mutates state and then fails can leave the old entry in place. Both outcomes come from the same rule.
Before Quarkus 3.32, the ordering was more dangerous. Invalidation could run before business logic finished. A concurrent reader could repopulate the cache with old data between invalidation and the actual write. The write might then succeed, leaving fresh data in the backing store and stale data in the cache, with no error to chase. PR #52200 fixed that ordering in 3.32.0.CR1 by sequencing invalidation after the method body. PR #53304 documented the success-only behavior in the guide and Javadoc for 3.34.2.
Reactive caching adds another surprise. @CacheResult on a method returning Uni does not behave like the blocking version in every detail. The cache stores the resolved value, not the Uni itself. If the Uni fails, nothing is cached. The Caffeine backend still gives you a cache-level single-flight guarantee on a miss: concurrent callers for the same key wait for the in-flight computation. The important distinction is that this is not Uni.memoize() for every subscriber. It deduplicates CDI method calls by cache key, and lockTimeout can deliberately let a waiting caller run the method itself.
We will build a small pricing service that makes those rules visible: reactive cache population, success-only invalidation, and a programmatic escape hatch for cases where stale data is worse than a cache miss.
Prerequisites
You need a working Quarkus CLI and a JDK. The sample was verified with Quarkus 3.34.5. No database or container runtime is required because the demo uses an in-memory catalog with the default Caffeine cache backend.
Java 21 installed
Quarkus CLI (
quarkus create app)Familiarity with CDI beans,
@Inject, and basic Mutiny (Uni)
Create The Project
Create the application:
quarkus create app com.themainthread:cache-failure-semantics \
--extension=cache,rest-jackson \
--java=21 \
--no-code--no-code skips the default GreetingResource. The listings below use their own packages, so the codestart would only add noise.
cd cache-failure-semanticsCreate the directories implied by each package line in the listings that follow. For example, com.themainthread.cache.service maps to com/themainthread/cache/service.
The cache extension gives us declarative Caffeine caching with @CacheResult, @CacheInvalidate, and @CacheInvalidateAll. Quarkus also supports Redis and Infinispan backends, but Caffeine keeps the example local and predictable. rest-jackson adds REST endpoints with Jackson JSON serialization.
Because --no-code creates an empty app, it does not add the REST test helper dependency. Add RestAssured next to the generated quarkus-junit test dependency in pom.xml:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Build The Service
We start with a tiny catalog, then put the cache boundary around reads and writes. Each block below is complete code.
In-memory catalog
Create src/main/java/com/themainthread/cache/service/CatalogStore.java:
package com.themainthread.cache.service;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class CatalogStore {
private final ConcurrentMap<String, BigDecimal> prices = new ConcurrentHashMap<>(Map.of(
"SKU-001", new BigDecimal("29.99"),
"SKU-002", new BigDecimal("49.99"),
"SKU-003", new BigDecimal("9.99")));
public BigDecimal getPrice(String sku) {
return prices.getOrDefault(sku, BigDecimal.ZERO);
}
public void updatePrice(String sku, BigDecimal newPrice) {
prices.put(sku, newPrice);
}
}This is not pretending to be a database. It gives us one useful property: the backing store can change while the cache still holds an older value.
Cache service: @CacheResult on Uni
Create src/main/java/com/themainthread/cache/service/PriceLookupService.java:
package com.themainthread.cache.service;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import io.quarkus.cache.CacheResult;
import io.smallrye.mutiny.Uni;
@ApplicationScoped
public class PriceLookupService {
private static final Logger LOG = Logger.getLogger(PriceLookupService.class);
@Inject
CatalogStore catalog;
private final ConcurrentMap<String, AtomicInteger> backendCalls = new ConcurrentHashMap<>();
@CacheResult(cacheName = "prices")
public Uni<BigDecimal> lookupPrice(String sku) {
backendCalls.computeIfAbsent(sku, ignored -> new AtomicInteger()).incrementAndGet();
LOG.infof("Cache miss for %s — calling backend", sku);
return Uni.createFrom()
.item(() -> catalog.getPrice(sku))
.onItem().delayIt().by(Duration.ofMillis(500));
}
public int backendCallsFor(String sku) {
AtomicInteger calls = backendCalls.get(sku);
return calls == null ? 0 : calls.get();
}
}The method returns Uni<BigDecimal>, but Quarkus caches the resolved BigDecimal, not the Uni instance. A failure is not cached, so the next call tries the backend again instead of replaying the same exception forever. The delay simulates a slow backend; the counter gives the tests a deterministic way to prove whether the method actually ran.
The Caffeine backend also provides lock-on-miss behavior per cache key. Two concurrent calls for SKU-001 do not both run the method when the cache is empty. One computes the value, the other waits. That is cache-level deduplication, not Uni.memoize().indefinitely() on a single Uni instance.
Write path with @CacheInvalidateAll
Create src/main/java/com/themainthread/cache/service/CatalogUpdateService.java:
package com.themainthread.cache.service;
import java.math.BigDecimal;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import io.quarkus.cache.Cache;
import io.quarkus.cache.CacheInvalidateAll;
import io.quarkus.cache.CacheManager;
@ApplicationScoped
public class CatalogUpdateService {
private static final Logger LOG = Logger.getLogger(CatalogUpdateService.class);
@Inject
CatalogStore catalog;
@Inject
CacheManager cacheManager;
@CacheInvalidateAll(cacheName = "prices")
public void updatePrice(String sku, BigDecimal newPrice, boolean simulateFailure) {
LOG.infof("Writing new price %s for %s", newPrice, sku);
catalog.updatePrice(sku, newPrice);
if (simulateFailure) {
throw new RuntimeException("Simulated failure after writing " + sku);
}
LOG.infof("Price updated successfully for %s", sku);
}
public void updatePriceWithForcedInvalidation(String sku, BigDecimal newPrice,
boolean simulateFailure) {
try {
LOG.infof("Writing new price %s for %s (forced invalidation path)", newPrice, sku);
catalog.updatePrice(sku, newPrice);
if (simulateFailure) {
throw new RuntimeException("Simulated failure after writing " + sku);
}
LOG.infof("Price updated successfully for %s", sku);
} finally {
Optional<Cache> cache = cacheManager.getCache("prices");
if (cache.isPresent()) {
cache.get().invalidateAll().await().indefinitely();
LOG.info("Programmatic cache invalidation completed");
}
}
}
}After Quarkus 3.32, @CacheInvalidateAll runs after the method completes. If the method throws, invalidation is skipped. The Application Data Caching guide documents the rule: invalidation happens after successful completion, and exceptions leave the cache untouched.
The demo throws after updating the in-memory store when simulateFailure is true. That is the failure that matters for cache staleness: a database commit succeeded, an external system changed, or some other side effect happened before a later step failed. If your write runs inside a transaction and the exception rolls everything back, keeping the old cache entry is usually the right outcome. If the side effect survives, the next reader sees stale cached data.
updatePriceWithForcedInvalidation is the escape hatch. It injects CacheManager, gets the prices cache, and invalidates in a finally block. The cache API is Uni-based, so .await().indefinitely() blocks this synchronous method until invalidation completes. In a reactive write path, return and compose the Uni instead.
REST resource
Create src/main/java/com/themainthread/cache/CacheResource.java:
package com.themainthread.cache;
import java.math.BigDecimal;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import com.themainthread.cache.service.CatalogUpdateService;
import com.themainthread.cache.service.PriceLookupService;
import io.smallrye.mutiny.Uni;
@Path("/prices")
public class CacheResource {
@Inject
PriceLookupService priceLookup;
@Inject
CatalogUpdateService catalogUpdate;
@GET
@Path("/{sku}")
public Uni<Response> getPrice(@PathParam("sku") String sku) {
return priceLookup.lookupPrice(sku)
.onItem().transform(price -> Response.ok(price).build());
}
@PUT
@Path("/{sku}")
public Response updatePrice(@PathParam("sku") String sku,
@QueryParam("price") BigDecimal price,
@QueryParam("fail") boolean fail) {
BigDecimal newPrice = price != null ? price : new BigDecimal("19.99");
try {
catalogUpdate.updatePrice(sku, newPrice, fail);
return Response.ok().build();
} catch (RuntimeException e) {
return Response.serverError().entity(e.getMessage()).build();
}
}
@PUT
@Path("/{sku}/force-invalidate")
public Response updatePriceForced(@PathParam("sku") String sku,
@QueryParam("price") BigDecimal price,
@QueryParam("fail") boolean fail) {
BigDecimal newPrice = price != null ? price : new BigDecimal("19.99");
try {
catalogUpdate.updatePriceWithForcedInvalidation(sku, newPrice, fail);
return Response.ok().build();
} catch (RuntimeException e) {
return Response.serverError().entity(e.getMessage()).build();
}
}
}The resource keeps the cache annotations on the service layer. GET /prices/{sku} stays reactive. The two PUT endpoints are synchronous because the update methods are synchronous. Both failure paths return 500, but they leave different cache state behind.
Configure The Cache
Add the following to src/main/resources/application.properties:
quarkus.cache.caffeine."prices".expire-after-write=10M
quarkus.cache.caffeine."prices".maximum-size=1000expire-after-write=10M is the upper bound on how long stale data can survive after a skipped invalidation. Ten minutes is a reasonable starting point for a pricing cache where freshness matters, but millisecond accuracy does not.
maximum-size=1000 keeps a high-cardinality key space from growing until it pressures the heap. One entry per SKU sounds harmless until the catalog stops being a demo.
The cache name "prices" must match the string in @CacheResult(cacheName = "prices") and @CacheInvalidateAll(cacheName = "prices"). A typo creates a second cache with defaults. You will not get a helpful error. You will get a cache that behaves as if it has a personal grudge against your assumptions.
Make It Survive Production
Concurrency and stampede protection
@CacheResult with the Caffeine backend uses an async value loader. For one cache key, only one computation runs at a time. If five callers request SKU-001 while the cache is empty, one call reaches the backend and the other four wait for the result.
The lockTimeout parameter on @CacheResult controls how long waiters stay in the lock-on-miss queue. The default is no timeout, which means indefinite waiting. If your backend hangs, every thread requesting that key hangs with it. When the timeout expires, the waiting thread gives up waiting and invokes the method itself. It does not throw an exception, and the timed-out invocation returns without caching its own result. That means a lockTimeout that is too short can cause the opposite problem: multiple threads compute the same value concurrently, which is exactly the stampede behavior the lock was designed to prevent. Set lockTimeout to a value shorter than your HTTP request timeout but long enough that the normal backend call completes before it fires. For most services, a few seconds works. If the timeout fires regularly, the real fix is a faster backend or a Uni-level timeout (see the next section), not a shorter lock.
Use Uni.memoize() when you need to deduplicate subscribers to one Uni instance. Use cache-level locking when independent CDI method calls share a cache key. They solve different problems that look annoyingly similar in a debugger.
Correctness boundary: exceptions and cache
Success-only invalidation is a correctness contract. It protects the cache from being emptied by a write that did not commit. The alternative, always invalidating, means a transient failure can clear hot entries and push every subsequent read back to the backend until the cache refills.
When stale data is dangerous, use the programmatic CacheManager path and accept the cache miss cost. Token caches, permission grants, and rate-limit counters are better candidates for forced invalidation than ordinary catalog reads.
Timeouts to slow backends
A cached method wrapping a remote call without a timeout means the cache loader blocks or the Uni never completes. Caffeine’s lock-on-miss holds other callers for the same key until the first call finishes. If that first call never finishes, those callers never return.
Add a timeout at the Uni level:
@CacheResult(cacheName = "prices")
public Uni<BigDecimal> lookupPrice(String sku) {
return Uni.createFrom()
.item(() -> catalog.getPrice(sku))
.onItem().delayIt().by(Duration.ofMillis(500))
.ifNoItem().after(Duration.ofSeconds(3)).fail();
}ifNoItem().after(Duration.ofSeconds(3)).fail() emits a TimeoutException if the Uni does not produce a value within three seconds. The cache does not store the failure, so the next call retries the backend. One slow SKU should not turn into a tiny distributed meditation exercise.
For services that also use SmallRye Fault Tolerance, @Timeout on the method works too. But @Timeout applies to the whole method, including cache lookup. The Uni-level timeout targets only the backend call, which is usually what you want.
Verification
Create src/test/java/com/themainthread/cache/CacheResourceTest.java:
package com.themainthread.cache;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import jakarta.inject.Inject;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import com.themainthread.cache.service.PriceLookupService;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CacheResourceTest {
@Inject
PriceLookupService priceLookup;
@Test
@Order(1)
void firstCallIsACacheMiss() {
int before = priceLookup.backendCallsFor("SKU-001");
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("29.99"));
assertEquals(before + 1, priceLookup.backendCallsFor("SKU-001"),
"First call should hit the backend");
}
@Test
@Order(2)
void secondCallIsACacheHit() {
int before = priceLookup.backendCallsFor("SKU-001");
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("29.99"));
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("29.99"));
assertEquals(before, priceLookup.backendCallsFor("SKU-001"),
"Cached calls should not hit the backend again");
}
@Test
@Order(3)
void successfulUpdateInvalidatesCache() {
int before = priceLookup.backendCallsFor("SKU-002");
given()
.when().get("/prices/SKU-002")
.then()
.statusCode(200)
.body(equalTo("49.99"));
assertEquals(before + 1, priceLookup.backendCallsFor("SKU-002"),
"Initial GET should populate the cache");
given()
.queryParam("price", "59.99")
.when().put("/prices/SKU-002")
.then()
.statusCode(200);
given()
.when().get("/prices/SKU-002")
.then()
.statusCode(200)
.body(equalTo("59.99"));
assertEquals(before + 2, priceLookup.backendCallsFor("SKU-002"),
"After successful invalidation the next GET should be a cache miss");
}
@Test
@Order(4)
void failedUpdateDoesNotInvalidateCache() {
int before = priceLookup.backendCallsFor("SKU-003");
given()
.when().get("/prices/SKU-003")
.then()
.statusCode(200)
.body(equalTo("9.99"));
given()
.when().get("/prices/SKU-003")
.then()
.statusCode(200)
.body(equalTo("9.99"));
assertEquals(before + 1, priceLookup.backendCallsFor("SKU-003"),
"Second GET should be served from cache");
given()
.queryParam("price", "12.99")
.queryParam("fail", true)
.when().put("/prices/SKU-003")
.then()
.statusCode(500);
given()
.when().get("/prices/SKU-003")
.then()
.statusCode(200)
.body(equalTo("9.99"));
assertEquals(before + 1, priceLookup.backendCallsFor("SKU-003"),
"After failed update the cache should still serve the old value");
}
@Test
@Order(5)
void forcedInvalidationClearsEvenOnFailure() {
int before = priceLookup.backendCallsFor("SKU-001");
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("29.99"));
int afterWarmup = priceLookup.backendCallsFor("SKU-001");
assertEquals(before + 1, afterWarmup,
"Warmup GET should repopulate SKU-001 after earlier invalidation");
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("29.99"));
assertEquals(afterWarmup, priceLookup.backendCallsFor("SKU-001"),
"SKU-001 should be cached before forced invalidation");
given()
.queryParam("price", "39.99")
.queryParam("fail", true)
.when().put("/prices/SKU-001/force-invalidate")
.then()
.statusCode(500);
given()
.when().get("/prices/SKU-001")
.then()
.statusCode(200)
.body(equalTo("39.99"));
assertEquals(afterWarmup + 1, priceLookup.backendCallsFor("SKU-001"),
"After forced invalidation the next GET should be a cache miss");
}
}Run the tests:
./mvnw testExpected output :
[INFO] Running com.themainthread.cache.CacheResourceTest
... Cache miss for SKU-001 — calling backend
... Cache miss for SKU-002 — calling backend
... Writing new price 59.99 for SKU-002
... Price updated successfully for SKU-002
... Cache miss for SKU-002 — calling backend
... Cache miss for SKU-003 — calling backend
... Writing new price 12.99 for SKU-003
... Cache miss for SKU-001 — calling backend
... Writing new price 39.99 for SKU-001 (forced invalidation path)
... Programmatic cache invalidation completed
... Cache miss for SKU-001 — calling backend
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
What the test proves:
At the start,
SKU-001triggers one backend call. The next two GETs do not increase the counter because the cache serves themSKU-002triggers a backend call, then a successful update changes the catalog and invalidates the cache, then the next GET triggers another backend call and returns59.99. Invalidation workedSKU-003triggers a backend call, the second GET is cached, the failed PUT changes the catalog to12.99and then throws, and the final GET does not increase the counter. The cache kept the old9.99valueSKU-001again in test 5: after the forced-invalidation PUT changes the catalog to39.99and then fails with 500, the cache is cleared anyway becauseCacheManager.invalidateAll()runs in thefinallyblock. The next GET is a cache miss and returns the updated value. This proves the programmatic escape hatch works
You can also test interactively with quarkus dev:
quarkus devIn another terminal:
curl http://localhost:8080/prices/SKU-001
# 29.99 (slow, cache miss)
curl http://localhost:8080/prices/SKU-001
# 29.99 (fast, cache hit)
curl -X PUT "http://localhost:8080/prices/SKU-001?price=39.99"
# 200 OK (cache invalidated)
curl http://localhost:8080/prices/SKU-001
# 39.99 (slow again, cache miss, reads the updated catalog value)
curl -X PUT "http://localhost:8080/prices/SKU-001?price=44.99&fail=true"
# 500 (method failed after changing the catalog, cache NOT invalidated)
curl http://localhost:8080/prices/SKU-001
# 39.99 (fast, cache still has the value from the previous miss)
curl -X PUT "http://localhost:8080/prices/SKU-001/force-invalidate?price=49.99&fail=true"
# 500 (method failed after changing the catalog, cache invalidated in finally)
curl http://localhost:8080/prices/SKU-001
# 49.99 (slow, cache miss, reads the updated catalog value)Conclusion
The service is small, but the failure mode is real. @CacheResult on Uni stores the resolved value and avoids poisoning the cache with failed asynchronous calls. @CacheInvalidateAll clears entries only after successful method execution. When a method can fail after a side effect survives, programmatic CacheManager invalidation is the explicit escape hatch.
The useful habit is to choose the invalidation rule from the data risk, not from habit. A stale product price and a stale permission grant do not deserve the same cache behavior.
The complete code is available in the cache-failure-semantics repository.


