Every backend codebase I have worked in has the same method somewhere. It fetches data from three or four downstream services, combines the results, and returns them. In Spring Boot services, that method almost always uses CompletableFuture.supplyAsync() chained into allOf(). And almost every one of those methods hides the same three problems: a failure that leaves other tasks running, exceptions wrapped two layers deep, and a thread dump that reads like a phone book.
Java has been working on a replacement for nine releases. Now it is actually done. JEP 543, Structured Concurrency, was integrated into JDK 28, finalizing the API without further change after two incubator rounds (JDK 19 and 20) and seven preview rounds (JDK 21 through 27). The review timeline and history are documented in the JEP itself, and InfoQ's October Java roundup tracked its final Proposed to Target step in early October.
Nine releases is an unusually long road for a JDK feature. It tells you two things. First, the API was genuinely hard to get right. Second, once JDK 28 ships, teams that avoided preview flags in production finally get to adopt it with zero flags and zero risk. That makes this the right moment to compare it honestly against the code it is meant to replace.
What Structured Concurrency actually is
The core idea is simple. Treat a group of related tasks running in different threads as a single unit of work. You open a scope, fork subtasks into it, join them as a unit, and the scope guarantees that no subtask outlives the block it was created in. If the parent fails or gets cancelled, everything inside the scope is cancelled with it.
If you have used Python's asyncio.TaskGroup or Kotlin coroutines' structured hierarchy, the mental model transfers directly. The JEP's stated goals are exactly the two pain points of the CompletableFuture style: eliminating risks from cancellation and shutdown such as thread leaks and cancellation delays, and improving observability of concurrent code.
One thing the JEP is explicit about, and that matters for how you read the rest of this article: Structured Concurrency is not a goal to replace ExecutorService or the rest of java.util.concurrent. It covers one very common pattern extremely well. It does not cover everything.
The old way: what CompletableFuture actually costs
Here is the typical fan-out as most of us write it today. The following example is the standard idiom, adapted from patterns in the JDK documentation:
ProductPage load(long id) {
CompletableFuture<Product> product =
CompletableFuture.supplyAsync(() -> productClient.fetch(id), executor);
CompletableFuture<Reviews> reviews =
CompletableFuture.supplyAsync(() -> reviewClient.fetch(id), executor);
CompletableFuture<Stock> stock =
CompletableFuture.supplyAsync(() -> stockClient.fetch(id), executor);
CompletableFuture.allOf(product, reviews, stock).join();
return new ProductPage(product.join(), reviews.join(), stock.join());
}
It works. It runs three calls in parallel and combines the results. But look closely at what happens when things go wrong:
-
No cancellation. If
productClient.fetch()fails,allOf().join()completes exceptionally, but the review and stock calls keep running to completion in the background. Their threads are occupied doing work whose result nobody will ever read. Under load, that is how a slow downstream turns into an exhausted pool. -
Exception gymnastics. The failure surfaces as a
CompletionExceptionwrapping the real cause, and each individual future fails the same way. Every error path needs.exceptionally()or.handle()or an unwrap, and it is easy to accidentally swallow the original stack trace. - Flat observability. The three tasks have no relationship to each other in the JVM's eyes. A JSON thread dump shows hundreds of anonymous pool threads. Finding the three that belong to this request is manual archaeology.
- Executor management. You either pass a shared executor everywhere or lean on the common pool, and either choice becomes a capacity decision you have to defend.
None of this is a bug in CompletableFuture. It was designed as a composable async pipeline primitive, and for that job it is good. The mismatch is that we keep using a pipeline tool for the "run these four calls, wait for all of them" job, and paying the tax above every time.
The new way: the same method with StructuredTaskScope
Here is the identical operation with the finalized API. The following example is adapted from the JEP 543 and JEP 505 documents:
ProductPage load(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<Product> product = scope.fork(() -> productClient.fetch(id));
Subtask<Reviews> reviews = scope.fork(() -> reviewClient.fetch(id));
Subtask<Stock> stock = scope.fork(() -> stockClient.fetch(id));
scope.join(); // waits for all; cancels the scope if one fails
return new ProductPage(product.get(), reviews.get(), stock.get());
}
}
The differences are not cosmetic. Each fork runs on its own virtual thread, so there is no executor to size or inject. If the product call fails, the default policy fails the whole scope and the other two subtasks are cancelled and joined before the block exits. Nothing leaks. Exceptions come out of join() with their real cause, not a wrapper. And because the scope is a real runtime structure, jcmd Thread.dump_to_file -format=json renders scopes and their threads as a tree, so you can see exactly which subtasks belong to which request.
Note also what the try-with-resources block enforces: closing a scope cancels and waits for any outstanding subtasks. Forking from a thread that does not own the scope fails, and improper nesting throws StructureViolationException. The structure is not a convention. The compiler and the runtime hold you to it.
Picking a completion policy: the built-in Joiners
The single-argument open() covers "all must succeed or the scope fails." Real fan-out needs more variety, and the finalized API ships four built-in joiners, described in the JEP:
-
Joiner.allSuccessfulOrThrow()- all subtasks must succeed.join()gives you the successful results as a stream, so you can map and collect them directly. -
Joiner.anySuccessfulResultOrThrow()- the race pattern. Fire N equivalent calls, first success wins and returns fromjoin(), the rest get cancelled. Perfect for multi-provider failover, like trying three upstream APIs and taking the fastest answer. -
Joiner.awaitAllSuccessfulOrThrow()- wait for all, throw if any failed. This is the behavior of the default no-argumentopen(). -
Joiner.allUntil(Predicate)- keep running until a condition you define, for example "cancel everything as soon as the first subtask fails but let me inspect all completed results." Custom joiners can go further by implementingonFork,onComplete,resultandtimeout.
One more piece of polish from the long preview road: JDK 25's JEP 505 replaced the public constructors with these static open() factory methods, and JDK 27's JEP 533 added a type parameter for the exception join() can throw. That is why the final shape is StructuredTaskScope<T, R, R_X>, where T is the subtask result type, R is what join() returns, and R_X is the exception it can throw. Static factories plus explicit exception types are exactly the kind of cleanup seven preview rounds are for.
Where CompletableFuture still wins
This is the part most comparison posts skip, and it is where the decision actually lives. Structured Concurrency is a blocking model: the calling thread waits inside join(). That is a feature in a virtual-thread world, where blocked virtual threads are cheap. But it makes StructuredTaskScope the wrong tool for several jobs:
- Event-driven pipelines. When completions arrive asynchronously over time and you react to each one (streaming, message handling, callback chains), you want composition of futures, not a scope that blocks.
-
Non-blocking backpressure. If you need reactive-style flow control over unbounded streams, neither
CompletableFuturenor structured scopes replace Project Reactor or similar libraries. -
Pre-JDK 25 codebases. On JDK 21 through 24 you get an older preview shape that needs
--enable-preview, which most teams correctly refuse in production. JDK 25 through 27 carry the near-final shape, still behind the flag. The clean, no-flag adoption point is JDK 28, which lands in the normal six-month cadence, so around March 2027. - Background jobs detached from a request. A scope ties subtask lifetime to the enclosing block. Scheduled work that must outlive the trigger genuinely is unstructured, and an executor is the honest tool for it.
The JEP says the quiet part out loud in its non-goals section: it does not replace java.util.concurrent. It gives the most common concurrent shape, fan out within one unit of work and wait, a structure that cannot leak.
What this means for Spring Boot services
For request-scoped fan-out, this slots into Spring Boot with no framework ceremony. With virtual threads enabled, a plain service method that opens a scope and joins is completely reasonable: the request thread is virtual anyway, so blocking in join() costs nothing. You do not need @Async, a task executor bean, or reactive return types for this pattern anymore.
Two practical notes if you plan the migration. First, the API shape moved between preview rounds, so any tutorial written before 2025, including several good ones from the JDK 21 era, shows API calls that no longer exist. Check the JEP's history section against whatever you copy. Second, the low-risk path is to introduce it at the edges: pick the service methods with the worst fan-out behavior, the ones where a slow downstream already causes pool exhaustion, and convert those first. CompletableFuture code that works does not need touching.
The following checklist is the version I would put in a team wiki:
-
Request must fan out and wait for several calls - StructuredTaskScope with the default or
allSuccessfulOrThrowjoiner. -
Same call to N providers, fastest answer wins -
anySuccessfulResultOrThrow. -
Tolerate partial failure, keep going until a condition -
allUntil(Predicate)or a custom joiner. -
Event streams, callbacks, non-blocking composition - stay on
CompletableFuture. - Work that outlives the request - executor, not a scope.
- Production today on JDK 21-27 - wait for JDK 28 unless you accept preview flags; prepare by finding your worst fan-out methods now.
The takeaway
Nine releases is what it takes when a platform decides that concurrency structure should be enforced by the runtime instead of by developer discipline. CompletableFuture asked you to remember to cancel, to unwrap exceptions, to size pools. StructuredTaskScope makes leaking a subtask a compile-and-runtime-level violation instead of a production incident you discover at 2 AM.
The honest summary of this comparison: for the request-scoped fan-out that dominates backend code, Structured Concurrency is simply the better tool, and after March 2027 there is no preview flag left to justify waiting. For everything asynchronous and event-driven, nothing changes. That is a rare outcome in API migrations: a clear win where it applies, and a clear boundary where it does not.
I write about Java, Spring Boot, and AI every week. Subscribe, it's free.
Have you restructured fan-out code with StructuredTaskScope on a preview build, or are you waiting for the JDK 28 GA? What does your team's parallel-call code look like today?
Sources and further reading:
- JEP 543: Structured Concurrency - final JEP, history of all nine rounds
- InfoQ Java News Roundup, October 2026 - finalization timeline and ecosystem context
-
Java news coverage of the final API shape - details on
StructuredTaskScope<T, R, R_X>and the JSON thread dump tree
Top comments (0)