From Seconds to Milliseconds: Quarkus Caching Made Easy
Learn how to supercharge your Java REST APIs with Quarkus’s built-in caching, turning slow responses into lightning-fast results.
Every enterprise developer has faced it: a service that feels slow. Maybe a database query takes too long. Maybe you’re calling a legacy system over a slow network. Maybe the calculation itself is heavy. The effect is the same, your API lags, and your users notice.
Two seconds doesn’t sound like much, but in modern microservices, it’s an eternity. Luckily, most data doesn’t change every millisecond. That’s where caching shines. By storing the result of an expensive operation and reusing it, you can transform a slow API into a blazing-fast one.
In this hands-on tutorial, you’ll learn how to:
Build a deliberately slow REST API in Quarkus.
Apply Quarkus caching with a single annotation.
Verify the speedup from seconds to milliseconds.
Invalidate cached data when needed.
We’ll use Quarkus with quarkus-rest-jackson
to expose JSON endpoints. You’ll see exactly how Quarkus’s caching extension works with minimal setup.
Prerequisites
Make sure you have:
JDK 17+ (download from Adoptium)
Maven 3.8+
Podman (or Docker if you prefer) – not required for this tutorial, but handy for later Redis cache setups
An IDE (IntelliJ, VS Code, or Eclipse)
curl or HTTPie for testing
Bootstrap the Quarkus Project
We’ll create a fresh Quarkus app with the REST Jackson and Cache extensions.
Using the Quarkus CLI:
quarkus create app org.acme:caching-tutorial \
--extension='rest-jackson,cache' \
--no-code
cd caching-tutorial
This sets up:
quarkus-rest-jackson
: provides JAX-RS + Jackson for REST endpoints.quarkus-cache
: adds cache annotations like@CacheResult
.
For convenience, I did push the code to my Github repository. Check it out and leave a star please!
Create the “Slow” Service
Instead of a real database, we’ll simulate slowness with Thread.sleep
.
src/main/java/org/acme/WeatherService.java
:
package org.acme;
import java.util.concurrent.TimeUnit;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class WeatherService {
public String getDailyForecast(String city) {
// Simulate slow work
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "The weather in " + city + " is sunny. (Timestamp: " + System.currentTimeMillis() + ")";
}
}
@ApplicationScoped
makes this a CDI bean managed by Quarkus.The timestamp proves later whether the result came from cache or recomputation.
Expose It with a REST Endpoint
Let’s publish our service as JSON.
src/main/java/org/acme/WeatherResource.java
:
package org.acme;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/weather")
public class WeatherResource {
@Inject
WeatherService service;
@GET
@Path("/{city}")
@Produces(MediaType.APPLICATION_JSON)
public Forecast forecast(@PathParam("city") String city) {
return new Forecast(city, service.getDailyForecast(city));
}
public record Forecast(String city, String forecast) {
}
}
This code is straightforward:
@Path("/weather")
defines the base URL for this resource.@Inject
is the magic of Dependency Injection; Quarkus provides theWeatherService
instance for us.@GET
and@Path("/{city}")
map an HTTP GET request to this method, capturing the city name from the URL.
It's time to see our slow service in action.
Run and Observe the Slowness
Start Quarkus in dev mode:
quarkus dev
Now test:
time curl http://localhost:8080/weather/london
Output (after ~2 seconds):
{
"city": "london",
"forecast": "The weather in london is sunny. (Timestamp: 1756538739789)"
}
And timing:
2.152 total
Run again with the same city:
time curl http://localhost:8080/weather/london
Still ~2 seconds. Every request recomputes and stays slow. Time to cache.
Apply @CacheResult
Let’s add the magic. And if you’re curious, keep Quarkus running. We can do all of below without having to restart. Quarkus will pick up the changes and live-reload for you.
Update WeatherService.java
:
package org.acme;
import java.util.concurrent.TimeUnit;
import io.quarkus.cache.CacheResult;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class WeatherService {
@CacheResult(cacheName = "weather-cache")
public String getDailyForecast(String city) {
// Simulate slow work
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "The weather in " + city + " is sunny. (Timestamp: " + System.currentTimeMillis() + ")";
}
}
What does this do? The @CacheResult
annotation, part of the standard JCache API, tells Quarkus to wrap this method in caching logic.
The first time the method is called with a specific
city
(e.g., "paris"), Quarkus executes the method body as usual.Before returning the result, it stores it in a cache named
weather-cache
. It automatically uses the method parameters (thecity
string) to generate a unique cache key.The next time the method is called with the same city, Quarkus finds the result in the cache and returns it immediately, completely skipping the method's execution.
Because you are running in Quarkus dev mode, this change was automatically reloaded. There's no need to restart the application!
Test Again! From Seconds to Milliseconds.
First call (slow):
time curl http://localhost:8080/weather/paris
Second call (instant):
time curl http://localhost:8080/weather/paris
Sample timing:
0.023 total
Notice the timestamp is identical. This is proof that the cached value was returned. For more details on key generation and other options, check out the official Quarkus Caching Guide.
Cache Invalidation
Our cache is great, but what if the weather forecast changes? We need a way to manually remove an entry from the cache to force a refresh on the next request. This is done with the @CacheInvalidate
annotation.
Update WeatherService.java
:
import io.quarkus.cache.CacheInvalidate;
@CacheInvalidate(cacheName = "weather-cache")
public void invalidateForecast(String city) {
// No body needed – the annotation does the work
}
}
Update WeatherResource.java
:
import jakarta.ws.rs.DELETE;
@DELETE
@Path("/{city}")
public void deleteCache(@PathParam("city") String city) {
service.invalidateForecast(city);
}
Now test:
Call
/weather/berlin
(slow, caches it).Call again (fast).
Invalidate:
curl -X DELETE http://localhost:8080/weather/berlin
Call again (slow, re-caches).
You now have full control over reading, populating, and removing items from your cache. For a deeper dive into invalidation strategies, see the JCache Annotation Guide.
Some Thoughts on Production
Default cache: In-memory Infinispan. Great for local or single-instance apps.
Scaling out: For clusters, plug in Redis or distributed Infinispan.
TTL (time-to-live): Configure eviction policies in
application.properties
:
quarkus.cache.caffeine.weather-cache.expire-after-write=60S
This keeps entries fresh for 1 minute.
Pitfall: Don’t cache highly dynamic or user-specific sensitive data (like tokens). Cache only what’s safe.
Conclusion
In less than 30 lines of code, you saw a Quarkus API speed up from 2 seconds to 2 milliseconds. With @CacheResult
, caching is declarative and effortless. With @CacheInvalidate
, you stay in control.
Caching is one of those low-effort, high-impact techniques every enterprise Java developer should master.