advanced~2h

Structured Concurrency

Treat a group of concurrent subtasks as one unit of work with a guaranteed beginning and end, with automatic cancellation propagation that a hand-assembled CompletableFuture chain has to wire up manually.

Learning objectives

  • Explain the unstructured-concurrency problem that motivated StructuredTaskScope
  • Fork and join subtasks using StructuredTaskScope and read their outcomes safely
  • Apply allSuccessfulOrThrow for fail-fast fan-out and anySuccessfulResultOrThrow for hedged requests
  • Recognize when a custom Joiner is warranted beyond the two built-in policies
  • Correctly nest a StructuredTaskScope inside a ScopedValue binding for context propagation

◆ Story

A search-and-rescue coordinator sends three scouts out in different directions at once, but the operation isn't structured as "send them off and hope." The coordinator stays at the command post until every single scout has reported back — success, failure, or "found nothing" — before the search is considered over. And if one scout radios in "found a cliff edge, dangerous," the coordinator doesn't wait for the other two to wander back on their own schedule; the recall signal goes out immediately, to all of them, at once. Nobody is left in the field unaccounted for when the coordinator closes the operation.

That discipline — every forked task's lifetime strictly nested inside the lifetime of the block that created it, with a guaranteed, all-accounted-for ending — is structured concurrency. It applies the same nesting rule that a local variable already follows (a variable's scope is nested inside its enclosing braces) to threads instead of variables. Before this existed, forking off several concurrent subtasks and having one of them fail meant the others simply kept running — holding connections open, making calls nobody would read the result of — because nothing in the older model could automatically tell them to stop. Structured concurrency, finalized as JEP 505, closes that gap: when the scope closes, every child task is guaranteed to be finished, one way or another.

The unstructured problem, stated concretely: fork three independent tasks against a raw ExecutorService and plain Futures, and task 2 throws. What happens to tasks 1 and 3? Nothing — they keep right on running, wasting resources, holding connections open, making calls whose results nobody will ever read, until they finish on their own or the JVM exits. Nobody told them to stop, because nothing in that model can automatically tell them to stop. Worse, a thread dump under this model shows child threads disconnected from whatever request or operation spawned them, making incident debugging harder than it needs to be.

StructuredTaskScope is the fix: subtasks are forked from inside a try-with-resources block, and the block cannot be exited while any forked subtask remains unaccounted for. scope.fork(callable) starts a subtask running — typically on its own virtual thread — and returns a handle (a Subtask) rather than the result directly. scope.join() is the synchronization point: it blocks until every forked subtask reaches a terminal state, and it's also what makes reading a subtask's outcome afterward safe — calling state() or get() on a subtask handle before join() has returned throws IllegalStateException, because the framework won't let you read a result it hasn't yet guaranteed is safe to read.

The plain StructuredTaskScope.open() shown below uses the default Joiner, which imposes no automatic cancellation policy at all — it simply waits for every subtask to reach a terminal state, leaving the caller to inspect each one's outcome individually. Built-in Joiners that add real cancellation policies on top of this foundation are covered next.

💻 Code example

package concurrency.structured.basics; import java.util.concurrent.StructuredTaskScope; /** * The default Joiner: fork subtasks, join() waits for every one to reach a * terminal state, then inspect each state()/get() individually. No * automatic cancellation policy is applied. */ public class StructuredTaskScopeBasics { public static void main(String[] args) throws Exception { try (var scope = StructuredTaskScope.open()) { // fork() starts each subtask running concurrently, typically on // its own virtual thread, as soon as it's called. var moduleOne = scope.fork(() -> "Module 1 Complete"); var moduleTwo = scope.fork(() -> "Module 2 Complete"); scope.join(); // Blocks until BOTH subtasks reach a terminal state. // Safe here specifically because join() already returned -- // that's the synchronization point publishing each outcome. System.out.println(moduleOne.state() + " | " + moduleOne.get()); System.out.println(moduleTwo.state() + " | " + moduleTwo.get()); } // The try-with-resources block cannot exit until every forked // subtask is fully accounted for. } }

Joiner.allSuccessfulOrThrow() is the policy reached for most often: fork several subtasks that must all succeed for the overall unit of work to mean anything at all — fetching a user's profile and their account balance together, where there's no point rendering a page with only one of the two. The moment either fails, keeping the other one running is pure waste. Under this Joiner, the first subtask to fail triggers cancellation of every sibling automatically, and scope.join() itself throws — wrapping the original cause in a StructuredTaskScope.FailedException — rather than returning normally.

The opposite shape is Joiner.anySuccessfulResultOrThrow(): you don't need every subtask to succeed, you need any one of them to. This is the hedged-request pattern — ask several redundant sources the same question simultaneously, take whichever answers first, and stop caring about the rest. The moment one subtask succeeds, every other sibling is cancelled automatically, and scope.join() itself returns the winning result directly rather than requiring you to fetch it off a handle. This trades a small amount of extra load for a large reduction in worst-case latency — a well-documented technique for taming tail latency, since even a service that's fast on average occasionally takes far longer on an individual call. It should be reserved for idempotent, side-effect-free reads: a "losing" call may have already had a real-world effect before it gets cancelled.

Achieving either of these behaviors by hand with CompletableFuture means manually composing combinators and explicit cancel() calls across every branch. Here, that entire cancellation-propagation responsibility is handled by the Joiner itself — the code reads linearly, like ordinary sequential logic, because structurally it now behaves like ordinary sequential logic.

💻 Code example

package concurrency.structured.allsuccessful; import java.util.concurrent.StructuredTaskScope; /** * Joiner.allSuccessfulOrThrow(): every subtask must succeed, or the first * failure cancels every sibling and join() throws. */ public class AllSuccessfulOrThrowDemo { record UserProfile(String name) {} record AccountBalance(double amount) {} static UserProfile fetchProfile(String userId) { return new UserProfile("User " + userId); } static AccountBalance fetchBalance(String userId) { return new AccountBalance(1250.75); } public static void main(String[] args) throws Exception { String userId = "u-77"; try (var scope = StructuredTaskScope.open( StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) { var profileTask = scope.fork(() -> fetchProfile(userId)); var balanceTask = scope.fork(() -> fetchBalance(userId)); scope.join(); // Throws StructuredTaskScope.FailedException if // EITHER subtask failed; cancels the other first. System.out.println(profileTask.get() + " | " + balanceTask.get()); } // If fetchBalance() had thrown, fetchProfile()'s subtask would be // interrupted and cancelled before it could ever complete, and the // final println would never execute. } }

Reading state() or get() on a subtask handle before join() returns. This throws IllegalStateException immediately, by design — join() is the actual synchronization point that publishes every subtask's outcome safely to the owning thread, exactly analogous to a plain Thread.join()'s happens-before guarantee. There's no way around this; the outcome genuinely isn't safe to read until join() has returned.

Opening the StructuredTaskScope outside an active ScopedValue binding when the subtasks need that context. Structured subtasks correctly inherit a ScopedValue binding that's active in the dynamic scope the scope itself was opened within — but the nesting order matters and isn't cosmetic. If StructuredTaskScope.open() is called outside the ScopedValue.where(...).run(...) lambda that establishes the binding — or the binding has already closed before a subtask forked from that scope actually runs — reading the ScopedValue inside that subtask throws NoSuchElementException, the same fail-fast behavior a ScopedValue always has, now surfacing across a thread boundary instead of within one.

Using anySuccessfulResultOrThrow() for non-idempotent operations. The "losing" subtasks in a hedged race are cancelled, but cancellation happens after they've started running — a losing subtask that had already made a real side-effecting call (charging a card, sending a message) before being cancelled has still had that real-world effect. This pattern is safe specifically for idempotent, side-effect-free reads, not for writes or anything with an external effect that can't simply be discarded.

A Joiner is an interface, not a fixed pair of choices. allSuccessfulOrThrow() and anySuccessfulResultOrThrow() are simply the two most common built-in implementations of it, covering the overwhelming majority of real fan-out/fan-in code. But other shapes exist: "wait for at least 3 of 5 replicas to confirm a write" (a quorum), or "collect every outcome, successful or not, and report which ones failed" (partial success). A custom Joiner is notified as each forked subtask completes and decides two things: whether that particular completion should trigger cancelling the rest of the scope's subtasks, and what scope.join() should ultimately return or throw once the scope finishes collecting outcomes. A quorum-style Joiner, for instance, would count successes as they arrive and only signal "cancel the rest" once enough have accumulated — everything before that threshold behaves like the default no-cancellation Joiner. Because the two built-ins cover so much ground already, reaching for a custom one is genuinely uncommon — worth double-checking the defaults don't already fit before writing one.

ScopedValue propagation into structured subtasks works because it's tied to dynamic scope, not thread identity. A ScopedValue binding established via ScopedValue.where(...).run(...) is automatically visible to any structured subtask forked from a StructuredTaskScope opened within that binding's active extent — correctly, regardless of which thread (virtual or platform, freshly created or drawn from a carrier pool) ends up actually running that subtask. This is precisely the propagation guarantee InheritableThreadLocal could never provide for pooled threads, since that mechanism only ever copies a value at thread-construction time.

💻 Code example

package concurrency.structured.scopedvalues; import java.util.concurrent.StructuredTaskScope; /** * A ScopedValue bound in the parent's dynamic scope is automatically * visible inside subtasks forked from a StructuredTaskScope opened within * it -- the nesting order (scope opened INSIDE the binding) is what makes * this work. */ public class ScopedValuePropagation { private static final ScopedValue<String> REQUEST_CONTEXT = ScopedValue.newInstance(); public static void main(String[] args) throws Exception { ScopedValue.where(REQUEST_CONTEXT, "request-ctx-123").run(() -> { try (var scope = StructuredTaskScope.open()) { // Forked while inside the active binding above, so this // subtask inherits it automatically, even though it runs // on its own (likely virtual) thread. var task = scope.fork(() -> "Inherited context: " + REQUEST_CONTEXT.get()); scope.join(); System.out.println(task.get()); } catch (Exception e) { throw new RuntimeException(e); } }); } }

Backend aggregation and fan-out services — the same "fetch a user profile and account balance together" or "hedge a request across replicas" shapes discussed earlier — are structured concurrency's primary real-world niche, and it's a direct upgrade over hand-assembled CompletableFuture chains for exactly this kind of code: automatic cancellation propagation replaces cancellation logic that previously had to be wired up, branch by branch, by hand.

Structured subtasks typically run on virtual threads by default, which is not a coincidence — the two features were designed to complement each other. Forking dozens or hundreds of structured subtasks per request is affordable specifically because each one is cheap, exactly the property virtual threads provide.

Compared against CompletableFuture, the trade-off is fairly clean: CompletableFuture offers more flexible, arbitrary composition and works all the way back to Java 8; structured concurrency offers linear, easier-to-read code, automatic cleanup, clear parent-child threading relationships (which shows up as cleaner thread dumps too), and cancellation handled entirely by the Joiner — at the cost of needing a recent JDK. For new Java 21+/25+ code built around fan-out/fan-in shapes, structured concurrency is generally the clearer default; existing CompletableFuture-based code doesn't need to be rewritten just to adopt it.

What guarantee does StructuredTaskScope give when its try-with-resources block exits? : Every subtask forked from that scope is guaranteed to have reached a terminal state -- no orphaned threads, no missed exceptions, no partial results left dangling.

Why does calling get()/state() on a subtask before join() throw? : join() is the synchronization point that safely publishes every subtask's outcome to the owning thread; reading before that point isn't guaranteed safe, so it throws IllegalStateException by design.

What's the difference between allSuccessfulOrThrow() and anySuccessfulResultOrThrow()? : allSuccessfulOrThrow(): every subtask must succeed or the first failure cancels the rest and join() throws. anySuccessfulResultOrThrow(): the first success cancels the rest and join() returns that winning result directly -- the hedged-request pattern.

When would you write a custom Joiner? : When neither built-in fits -- quorum-style consensus ("N of M must succeed") or "collect every outcome including failures for reporting" are the common cases that don't reduce to either default.

Why must a StructuredTaskScope be opened INSIDE a ScopedValue binding for propagation to work? : Subtasks inherit a ScopedValue binding based on the dynamic scope active when they're forked. If the scope is opened outside the binding, or the binding closes before a forked subtask runs, reading that ScopedValue inside the subtask throws NoSuchElementException.

Want a visual for this concept?

Generate a diagram tailored to “Structured Concurrency” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Concurrency Patterns & Best Practices← Back to all Java Concurrency & Multithreading chapters