# Structured Concurrency in Java 25
URL: https://chornonoh-vova.com/blog/structured-concurrency-java-25/
Date: 2026-08-06
Imagine you're building a dashboard for an e-commerce application.
When a user opens the page, they expect to see everything at once:
- their profile
- recent orders
- unread notifications
- personalized recommendations
Nothing extraordinary. Just another REST endpoint.
```http
GET /dashboard
```
But, in our imaginary application, these pieces of information don't live in
the same place, each of them is in a separate service, so our API needs to
call them before returning the response.
The easiest implementation is the most obvious one:
1. Call each service
2. Wait for the response
3. Move to the next service
```java
@GetMapping("/dashboard")
public DashboardDto getDashboard() {
log.info("Starting dashboard request");
DashboardDto dashboard = new DashboardDto();
dashboard.setProfile(profileService.getProfile());
dashboard.setOrders(ordersService.getOrdersList());
dashboard.setNotifications(notificationsService.getNotificationsList());
dashboard.setRecommendations(recommendationsService.getRecommendationsList());
log.info("Finishing dashboard request");
return dashboard;
}
```
A note on the services
Each of the four services looks the same way:
```java
@Slf4j
@Service
public class NotificationsService {
private final RestClient restClient = RestClient.create();
public List getNotificationsList() {
log.info("Starting notifications request");
List notifications = restClient.get()
.uri("http://notifications-service/notifications")
.retrieve()
.body(new ParameterizedTypeReference<>() {});
log.info("Finishing notifications request");
return notifications;
}
}
```
The other three differ only in the name and the endpoint. In the demo I ran to
collect the output below, each of the services calls a local endpoint that just
sleeps for a fixed number of milliseconds rather than a real service, so the timings
stay reproducible without standing up four actual services.
> That `log` is coming from Lombok's `@Slf4j` annotation, and each of the four services
is written the same way — one log line on the way in, one on the way out.
Here's a small visualization of what is going on — expand **Show logs** underneath
it to see what the application prints while those bars fill:
Each service is requested sequentially, therefore the duration of this API
request is the sum of durations of all of the calls to other services.
Ok, it's not ideal, but we can make the endpoint perform better by parallelizing
each of those calls!
```java
@GetMapping("/dashboard")
public DashboardDto getDashboard() throws InterruptedException, ExecutionException {
log.info("Starting dashboard request");
CompletableFuture profileFuture =
CompletableFuture.supplyAsync(() -> profileService.getProfile());
CompletableFuture> ordersFuture =
CompletableFuture.supplyAsync(() -> ordersService.getOrdersList());
CompletableFuture> notificationsFuture =
CompletableFuture.supplyAsync(() -> notificationsService.getNotificationsList());
CompletableFuture> recommendationsFuture =
CompletableFuture.supplyAsync(() -> recommendationsService.getRecommendationsList());
CompletableFuture.allOf(
profileFuture, ordersFuture, notificationsFuture, recommendationsFuture
).join();
DashboardDto dashboard = new DashboardDto();
dashboard.setProfile(profileFuture.get());
dashboard.setOrders(ordersFuture.get());
dashboard.setNotifications(notificationsFuture.get());
dashboard.setRecommendations(recommendationsFuture.get());
log.info("Finishing dashboard request");
return dashboard;
}
```
In this example, we start all of the futures asynchronously and execute them
in parallel, then we wait for all of the results in `allOf(...).join()`.
There's one detail here that comes back later. `supplyAsync` without a second
argument runs the task on `ForkJoinPool.commonPool()`. That pool is sized for CPU
work, one thread fewer than your core count, and it's shared with every parallel
stream and every other `CompletableFuture` in the JVM. We've just given it four
tasks that do nothing but wait on a socket.
Here's what it looks like, visualized:
In this case, the total duration of the execution will be the maximum
duration of one of the service requests (in this case, recommendations
service). 890ms down to 310ms (almost 3x speedup!), and we barely touched the code.
## What happens when one of them fails
Start with the sequential version, where the answer is boring. That's the point:
it's the baseline that parallelism quietly takes away.
Notifications service throws, and that's the end of it. Recommendations service is
never called, because the line that would have called it never runs. There's no
cleanup to think about and nothing left over: the stack unwinds and takes the whole
request with it.
Let's take a look now at the same failure but in the parallel version:
Look at how much later the exception reaches the log than the failure that caused
it. `allOf(...).join()` waits for *every* future, and it has no notion of one of
them having already made the result worthless. So orders and recommendations carry
on to their natural end, log their "Finishing" lines into a request that is already
dead, and only then does the `CompletionException` surface.
The numbers are right there in the diagram. We know about the failure at 170ms,
the caller hears about it at 310ms, and 230ms of thread time goes into two results
that nobody reads. On a single request that's just untidy. At a few hundred requests
a second, that's the common pool spending its threads on answers that are already
garbage instead of on requests that could still succeed.
The obvious fix is to cancel the futures we don't need anymore. It doesn't work.
The javadoc for `CompletableFuture.cancel` is blunt about why:
> `mayInterruptIfRunning` — this value has no effect in this implementation
> because interrupts are not used to control processing.
And the javadoc isn't overstating it. Decompile `cancel` and the boolean
parameter is never read at all: the method completes the future with a
`CancellationException` and returns. `cancel(true)` gives you back `true`,
`isCancelled()` starts answering `true`, and the thread underneath keeps running to
the end. We cancelled our view of the result. The work carries on.
So this is what we actually want:
## Doing it by hand
Getting that behaviour isn't a matter of adding a few `cancel` calls to what we
already have. `CompletableFuture` can't express it at all. Interruption only works
through `Future.cancel` on a real `ExecutorService`, so we have to put
`CompletableFuture` down and pick up a different set of APIs: `ExecutorService`,
`Future` and `ExecutorCompletionService`.
That version looks like this:
```java
@GetMapping("/dashboard")
@SuppressWarnings("unchecked")
public DashboardDto getDashboard() throws InterruptedException, ExecutionException {
log.info("Starting dashboard request");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var completion = new ExecutorCompletionService