Structured Concurrency (Java 21+)
StructuredTaskScope (Java 21 preview, finalised in 25) ties subtasks to a parent scope. The scope owns lifetimes: when it exits, all subtasks are cancelled or joined. Replaces the unstructured spawn-and-forget pattern of plain ExecutorService for fan-out work. Pairs naturally with virtual threads.
What it is
StructuredTaskScope is Java's structured concurrency API, finalised in Java 25 after several rounds of preview. It changes how fan-out code is written.
Before structured concurrency, fan-out in Java looked like: get an ExecutorService, submit several Callables, collect the futures, call get() on each, handle exceptions, manually cancel siblings on failure, remember to shutdown() the executor. Lots of bookkeeping. Easy to leak threads or forget to cancel on error.
Structured concurrency wraps the entire fan-out in a try-with-resources block. The scope owns the subtasks. When the block exits, every subtask is either complete or cancelled. The compiler enforces it.
The shape
Open a scope. Fork subtasks. Join. Read results. Close.
try (var scope = StructuredTaskScope.open(joiner)) {
var a = scope.fork(() -> fetchA());
var b = scope.fork(() -> fetchB());
scope.join();
return combine(a.get(), b.get());
}
The joiner decides what "join" means: wait for all to succeed, wait for any one, wait for any to complete (success or failure). Different joiners give different fan-out semantics from the same primitive.
The two joiners that matter
allSuccessfulOrThrow: every subtask must succeed. The first failure cancels the rest. Use when every result is needed to compose the answer (the order page that needs user, cart, and recommendations).
anySuccessfulOrThrow: as soon as one succeeds, cancel the rest. Use for hedged calls (try three replicas, take the first answer) and for fallback chains.
There are others (awaitAll, custom joiners), but these two cover the bulk of real use cases.
Why this matters
Three concrete wins.
Cancellation is reliable. When the scope exits, every still-running subtask is interrupted. No more "I forgot to cancel siblings on the error path" bugs.
Errors propagate naturally. A failed subtask with allSuccessfulOrThrow cancels the others and surfaces the exception at join(). One try, not one per subtask.
Virtual threads make it cheap. Forking a subtask is a virtual thread. Spawning 100 of them is microseconds and a few KB. Suddenly fan-out-and-wait is a normal idiom, not something to budget for.
When not to use it
For fire-and-forget background work that should outlive the request, an unstructured ExecutorService is the right tool. Structured scope is for fan-out that the parent waits for.
For pipelines of dependent stages (do A, then B with A's result, then C with B's result), CompletableFuture chains are still cleaner. Structured scope shines for parallel siblings, not for sequential dependencies.
For non-Java code or pre-Java 25, the API is not available. Fall back to manually-managed ExecutorService, accepting that the structuring becomes the caller's responsibility.
A note on the API shape
The exact API has shifted across previews (ShutdownOnFailure, ShutdownOnSuccess in earlier versions; Joiner in the finalised version). The mental model is stable; the method names are not. Always check the JDK's documentation for the exact spelling.
Primitives by language
- StructuredTaskScope.open() (Java 25+) / try-with-resources scope
- scope.fork(Callable) / scope.fork(Runnable)
- scope.join() / scope.joinUntil(Instant)
- Configurable joiners (anySuccessful, allSuccessfulOrThrow)
- Subtask.get / Subtask.exception / Subtask.state
Implementation
Three independent calls. All three are required. If any throws, cancel the others and propagate. Without StructuredTaskScope, the caller would manually track futures, handle cancellation on first failure, and ensure the others get interrupted. Scope handles all of that.
1 import java.util.concurrent.StructuredTaskScope;
2
3 record OrderView(User user, Cart cart, Recommendations recs) {}
4
5 OrderView load(String userId) throws Exception {
6 try (var scope = StructuredTaskScope.open(
7 StructuredTaskScope.Joiner.allSuccessfulOrThrow())) {
8
9 var userTask = scope.fork(() -> userService.fetch(userId));
10 var cartTask = scope.fork(() -> cartService.fetch(userId));
11 var recsTask = scope.fork(() -> recsService.fetch(userId));
12
13 scope.join(); // all done or any failed
14
15 return new OrderView(userTask.get(), cartTask.get(), recsTask.get());
16 }
17 }Call three replicas in parallel. As soon as one succeeds, cancel the other two and return. Pattern used to cut tail latency: the slow replica still finishes (and is cancelled), but the fast one already answered. Cuts p99 dramatically when replicas have varying response times.
1 import java.util.concurrent.StructuredTaskScope;
2
3 String fetch(List<String> replicas, String key) throws Exception {
4 try (var scope = StructuredTaskScope.open(
5 StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow())) {
6
7 for (String r : replicas) scope.fork(() -> readFrom(r, key));
8
9 return scope.join(); // first success
10 }
11 }joinUntil sets a wall-clock deadline. If the scope has not finished by then, every still-running subtask is cancelled and the join throws. Used at request boundaries to enforce a hard latency budget.
1 import java.time.Duration;
2 import java.time.Instant;
3
4 Result loadWithBudget(String userId) throws Exception {
5 try (var scope = StructuredTaskScope.open()) {
6 var t1 = scope.fork(() -> primary(userId));
7 var t2 = scope.fork(() -> fallback(userId));
8
9 scope.joinUntil(Instant.now().plus(Duration.ofMillis(200)));
10
11 return t1.state() == Subtask.State.SUCCESS ? t1.get() : t2.get();
12 }
13 }Even when join is not called, the try-with-resources close cancels every still-running subtask. No leaks, no orphaned threads. Compare this to plain ExecutorService where forgetting to shutdown leaves threads running until the JVM exits.
1 try (var scope = StructuredTaskScope.open()) {
2 scope.fork(() -> longRunningTask());
3 throw new RuntimeException("oops"); // close still runs
4 } // <-- scope.close() interrupts longRunningTask, then throwsKey points
- •Subtasks are scoped to a try-with-resources block. Scope close = all subtasks cancelled or joined.
- •Fork-join is structured: parent waits for children, children inherit deadlines and cancellation.
- •anySuccessful joiner: first success cancels siblings. Used for hedged calls, fastest-replica reads.
- •allSuccessfulOrThrow: all must succeed or all are cancelled. Used for fan-out where every result matters.
- •Pairs naturally with virtual threads: cheap to spawn 1000 subtasks, scope cleans up reliably.
Follow-up questions
▸Why is this called 'structured' concurrency?
▸How does this interact with virtual threads?
▸What about exceptions?
▸Is this stable yet?
Gotchas
- !scope.join() must be called before reading any subtask result. Otherwise IllegalStateException.
- !Subtask.get() before join() also throws. Order matters.
- !Forgetting try-with-resources defeats the structuring. Always use the resource block.
- !Joiners are typed by result type. Mixing forks with different return types complicates the joiner.
- !Subtasks inherit the parent's interrupt status. Cancel propagates downward through nested scopes.