You upgraded the project to JDK 25, ran the build and got slapped in the face: your StructuredTaskScope no longer compiles. The ShutdownOnFailure class is gone, throwIfFailed() went up in smoke and the IDE paints everything red. Before you curse Brian Goetz, breathe. The good news is the new API is leaner and harder to misuse. The annoying news is that nobody put a giant banner in the release notes saying "hey, this actually changed".
This article is the migration map. We will see what left, what arrived, how to translate each old pattern into the new one, how to handle errors and timeouts the new way, and why this topic became a senior interview question in 2026. All with code that compiles on JDK 25.
What structured concurrency is, in one honest sentence
Structured concurrency treats a group of concurrent tasks as a single unit of work. If you already understand try-with-resources, you are halfway there: you open a scope, fork the subtasks inside it, and when the block closes nothing keeps running loose. Either everything finishes together, or everything is cancelled together. No orphan thread, no lost CompletableFuture leaking a connection in the pool.
The idea sounds simple, but it solves one of the biggest pains of classic concurrency in Java: the lack of a clear relationship between who started a task and who is responsible for it. With a loose ExecutorService, a child task could outlive the request that created it. With structured concurrency, the subtask lifetime is bound to the lexical scope of the block.
The feature was born inside Project Loom, went through several previews between JDK 21 and 24, and reached on JDK 25 the shape described in JEP 505. The detail that catches everyone is that this shape is not compatible with what you wrote in earlier previews.
The problem the old API had
The preview API revolved around two ready-made subclasses: ShutdownOnFailure and ShutdownOnSuccess. You instantiated one of them with new, deciding the shutdown policy in the constructor. It worked, but it had two annoyances.
First: the policies were fixed. If you wanted something between "one failed, cancel all" and "one succeeded, cancel all", you had to extend StructuredTaskScope and reimplement the handle method by hand. Few people did that right.
Second: the flow had one extra step that was easy to forget. With ShutdownOnFailure you called join() and then throwIfFailed(). Forgetting the second one meant moving on with a half-baked result and no error in your face.
Here is the classic fan-out pattern (fetch two things in parallel) in the old API:
Response handle(long id) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join();
scope.throwIfFailed();
return new Response(user.get(), order.get());
}
}
Notice the lonely throwIfFailed(). It is the kind of line that vanishes in a refactor and nobody notices until production returns incomplete data.
What changed in JDK 25
The JEP 505 redesign made three big swaps.
First, the constructors are gone. You no longer write new StructuredTaskScope.ShutdownOnFailure(). Now there is the static factory method StructuredTaskScope.open(). The ShutdownOnFailure and ShutdownOnSuccess subclasses were removed for good, and StructuredTaskScope became a sealed interface instead of a class.
Second, the Joiner interface arrived. Instead of picking the policy via a subclass, you pass a Joiner that describes how subtasks join. The most common ones come from ready factory methods:
Joiner.allSuccessfulOrThrow(): waits for all to succeed and returns a stream of subtasks. If one fails, it cancels the others and throws.Joiner.anySuccessfulResultOrThrow(): returns the first successful result and cancels the rest. The direct replacement for the old ShutdownOnSuccess.Joiner.awaitAllSuccessfulOrThrow(): waits for all to succeed, without returning a stream.Joiner.awaitAll(): waits for all to finish, success or not, with no automatic cancellation.
Third, join() stopped being a method that only blocks and now returns the Joiner result.
The same fan-out from before, now on JDK 25:
Response handle(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join();
return new Response(user.get(), order.get());
}
}
The argument-less open() already comes with the "all succeed or throw" policy. throwIfFailed() no longer exists because join() does that job. Fewer lines, fewer gotchas.
From CompletableFuture to StructuredTaskScope
The most common case in production today is fan-out with CompletableFuture:
Response handle(long id) {
var exec = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<User> userF =
CompletableFuture.supplyAsync(() -> findUser(id), exec);
CompletableFuture<Order> orderF =
CompletableFuture.supplyAsync(() -> fetchOrder(id), exec);
CompletableFuture.allOf(userF, orderF).join();
return new Response(userF.join(), orderF.join());
}
If findUser throws early, fetchOrder keeps running to the end, burning a database connection for a result nobody will use. There is no cascading cancellation, and exceptions come wrapped in CompletionException. Structured concurrency fixes the three points at once: automatic cancellation, guaranteed close via try-with-resources and unwrapped exceptions.
Pattern by pattern migration
The "wait for all, one failure cancels everything" case was ShutdownOnFailure. Now it is plain StructuredTaskScope.open(), and you delete throwIfFailed().
The "first to respond wins" case was ShutdownOnSuccess. Now it is StructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow()):
String queryFastest() throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> queryReplica("us-east"));
scope.fork(() -> queryReplica("eu-west"));
return scope.join();
}
}
Collecting many results at once
When the Joiner is allSuccessfulOrThrow(), join() returns a Stream<Subtask<T>>:
List<Integer> prices(List<Callable<Integer>> queries) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
queries.forEach(scope::fork);
return scope.join()
.map(Subtask::get)
.toList();
}
}
No shared mutable list across threads, no synchronized on collection.
Timeout and scope name
The old joinUntil(Instant) is gone. On JDK 25, configuration moved to a second parameter of open():
Checkout finish(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow(),
cf -> cf.withName("checkout")
.withTimeout(Duration.ofMillis(800)))) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Cart> cart = scope.fork(() -> loadCart(id));
scope.join();
return new Checkout(user.get(), cart.get());
}
}
If time runs out, join() cancels pending subtasks and throws. The scope name set with withName shows up in the structured thread dump and makes production diagnosis far easier.
Error handling and subtask inspection
With Joiner.allSuccessfulOrThrow(), any failure makes join() throw StructuredTaskScope.FailedException, and the real cause is available via getCause():
Response handleSafe(long id) {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join();
return new Response(user.get(), order.get());
} catch (StructuredTaskScope.FailedException e) {
throw new ServiceException("failed to build response", e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("interrupted", e);
}
}
For fine-grained control, inspect each subtask via its state instead of letting join() throw, using Joiner.awaitAll() to collect partial results resiliently.
Each fork runs on a virtual thread
By default each fork runs on a virtual thread. That is why structured concurrency and Project Loom go hand in hand. If you are still fighting the side effects, review how to detect thread pinning in virtual threads on Java 25, because a misplaced synchronized inside a fork can still ruin the gain. The full track is in the Java articles section at Meu Universo Nerd.
The perfect pair: Scoped Values
When you fork tasks, you often need to pass context to them (the authenticated user, the request id, the locale). The automatic reaction is ThreadLocal, which backfires with thousands of virtual threads. JDK 25 also finalized Scoped Values, described in JEP 506: StructuredTaskScope organizes the tasks and ScopedValue carries the context between them. The official docs cover both in the Oracle structured concurrency guide.
Why this shows up in senior interviews
In 2026, asking "how do you coordinate several parallel calls in Java?" became a dividing line. The mid-level answer is "CompletableFuture.allOf and join". The senior answer is "it depends on the pattern: if I need all, I open a StructuredTaskScope with allSuccessfulOrThrow; if it is first to respond, anySuccessfulResultOrThrow; and cancellation and timeout come for free with the scope". Knowing why throwIfFailed() disappeared shows you followed the language evolution.
Migration checklist
- Replace every
new StructuredTaskScope.ShutdownOnFailure()withStructuredTaskScope.open()and delete the matchingthrowIfFailed(). - Replace every
new StructuredTaskScope.ShutdownOnSuccess<T>()withStructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow())and letjoin()return the result, removingresult(). - Swap
joinUntil(Instant)forcf -> cf.withTimeout(Duration)in the second argument ofopen(). - Review your catch blocks: handle StructuredTaskScope.FailedException and use
getCause(). - Where you collected results in a loop, switch to the
join()that returns Stream<Subtask<T>> withallSuccessfulOrThrow(). - Run your concurrency tests. If you have none, this is a great moment to write the first.
Conclusion
The structured concurrency redesign in JDK 25 is one of those changes that scares you on the first broken build and that you thank on the first code review. The API got smaller, cancellation and timeout became first-class citizens, and the join policies got clear names via Joiner. Migrating is more about translating patterns than rewriting logic. Keep an eye out for the next article in the series, about Scoped Values, here at Meu Universo Nerd.