The classic pain of parallel code: a method launched three subtasks, one failed — and the other two keep burning resources with nobody to cancel them. Or the reverse: the method returned, but an "orphaned" subtask is still alive, writing to the log a minute after the client got the response. Structured concurrency solves this with one principle: the lifetime of subtasks is bounded by the block of code that spawned them. In Java this principle is embodied by StructuredTaskScope.
The problem: unstructured fan-out
Let's assemble a user profile from two sources with an ExecutorService:
Future<User> userF = executor.submit(() -> fetchUser(id));
Future<List<Order>> ordersF = executor.submit(() -> fetchOrders(id));
User user = userF.get(); // and if fetchOrders already failed?
List<Order> orders = ordersF.get(); // we only find out here
Three chronic diseases live here. Leaked work: if fetchUser threw, get() propagates the exception — but nobody cancelled fetchOrders, and it runs to completion for nothing. Lost errors: had fetchOrders failed first, we wouldn't know until we reach its get(). A severed link: the call stack doesn't show these tasks are children of our method; the parent thread and the subtasks live separate lives, and cancelling the parent doesn't touch the children.
CompletableFuture with allOf smooths over part of this, but cancelling the "siblings" on the first failure and tying tasks firmly to the spawning block still has to be assembled by hand.
The idea: concurrency with block structure
The analogy that explains everything: once upon a time we programmed with goto, and control flow jumped anywhere; structured programming introduced blocks — enter at the top, exit at the bottom — and code became readable. Structured concurrency does the same for threads: subtasks live strictly inside a scope, and you cannot leave the scope until every subtask has finished — by success, failure, or cancellation.
The guarantees follow automatically: no subtask outlives its parent; errors are collected in one place; cancellation propagates top-down.
StructuredTaskScope: the Java 25 API
Response handle(long id) throws InterruptedException {
try (var scope = StructuredTaskScope.<Object>open()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // wait for all; on any failure — cancel the rest and throw
return new Response(user.get(), orders.get());
} // leaving the try guarantees: no live subtasks remain
}
The breakdown:
open()creates the scope;try-with-resourcesguarantees cleanup — however the block ends, all subtasks are awaited or cancelled.fork(...)launches a subtask on its own virtual thread and returns aSubtask— a "claim ticket" for the result.join()is the single waiting point. The default policy: all succeed — carry on; any one fails — the rest are cancelled, andjoin()throws aFailedExceptionwith the cause inside.subtask.get()after join simply picks up the finished value — no blocking involved.
Leaked work, lost errors, and orphaned tasks disappear not through discipline but by construction: there is nowhere left to express them.
Joiner: waiting policies
What counts as the scope's success is configured via a Joiner:
// First successful result — cancel the rest (racing mirrors/replicas):
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> fetchFrom(mirrorA));
scope.fork(() -> fetchFrom(mirrorB));
return scope.join(); // returns the winner's result itself
}
// Wait for ALL regardless of outcome and sort it out manually (partial results):
try (var scope = StructuredTaskScope.open(Joiner.<Widget>awaitAll())) { ... }
Plus scope configuration — for example, a shared deadline:
StructuredTaskScope.open(Joiner.allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofSeconds(2)));
// didn't make it — all subtasks are cancelled, join() throws TimeoutException
A timeout on the group as a whole — what took a pile of workarounds with futures is one line here.
One more ensemble detail: ScopedValue bindings are inherited by subtasks automatically — request context (user, trace id) flows into the fan-out with no manual plumbing.
Status and practice
StructuredTaskScope is a preview feature (JEP 505, fifth preview in Java 25): it requires the --enable-preview flag, and the API has changed before — in Java 21–24, instead of open() there were the ShutdownOnFailure/ShutdownOnSuccess classes (you will meet them in articles and codebases from those years). The idea is stable; check the signatures against your JDK.
The practical frame today: in libraries and long-lived production code — carefully (preview is preview); in services on a fresh JDK, utilities, and new modules — already a real candidate wherever the "send N requests — gather the answers" pattern appears. The forward-looking rule of thumb is simple: where you write CompletableFuture.allOf or a batch of submit/get today, tomorrow there will be a scope.
In short
- Unstructured fan-out problems: leaked work, lost errors, orphaned subtasks, hand-rolled cancellation.
- The principle: subtasks live inside a scope; leaving the scope = all subtasks finished. It's structured programming applied to threads.
- The API:
open()→fork()→join()→get(); try-with-resources guarantees cleanup; one subtask's failure cancels the rest. - The Joiner sets the policy: all succeed / first success / await all; configuration adds a group deadline.
- ScopedValue is inherited by subtasks — context flows by itself. Status: preview (JEP 505); the API differed in 21–24.
What to read next
- Virtual threads — what fork runs on and why "a thread per subtask" is cheap again.
- CompletableFuture — async chains: when the result is needed "later," not "here."
- Thread pools: ExecutorService — the classic layer that scopes are gradually displacing from application code.