Structured Concurrency in Java
Executive Summary
Structured concurrency in Java exists because unstructured concurrency leaks. A Future from an executor has no relationship to the caller. As a result, a failed sibling keeps running, a cancelled parent leaves orphans, and a hung task outlives its purpose silently. StructuredTaskScope fixes the relationship. Inside a try-with-resources scope, fork starts subtasks on virtual threads, and join waits for them. Then the scope’s policy decides the shared fate. For example, ShutdownOnFailure cancels every sibling the moment one fails, while ShutdownOnSuccess takes the first winner and cancels the rest. The block is the lifetime. No subtask can outlive the scope, exactly as no callee outlives a method call, per the StructuredTaskScope documentation.
However, the API is preview in Java 21, enabled with –enable-preview, and likely to keep evolving. So code to the concept, parent-owned groups with explicit policies, and expect method names to shift before stabilization. For fan-out with shared fate, it replaces the manual bookkeeping of Future cancellation loops and CompletableFuture recovery chains. In contrast, for batch work without shared fate, invokeAll remains the simpler, finished tool.
The Problem: Concurrency Without a Lease
Every unstructured pattern shares one flaw: the caller starts work and then hopes. Here is the canonical leak, the one that appears in codebases that adopted executors without adopting lifecycles:
// WRONG: scatter with no leash - two subtasks, no shared fate
try (var pool = Executors.newFixedThreadPool(4)) {
var price = pool.submit(() -> fetchPrice(sku)); // subtask 1
var reviews = pool.submit(() -> fetchReviews(sku)); // subtask 2
var p = price.get(2, TimeUnit.SECONDS); // fetchPrice times out: throws here
var r = reviews.get(2, TimeUnit.SECONDS); // never reached
} // the catch: fetchReviews is STILL RUNNING. The exception unwound the block,
// the pool closed at the end, but the in-flight subtask was never cancelled.
// Multiply by a request path under load: orphaned work, wasted connections,
// and an error budget paying for calls nobody wants anymore.
Read what is missing structurally. There is no cancellation propagation: the caller’s failure does not stop the sibling. There is no failure domain either. The two calls are one logical operation, a product page, but the machinery sees two unrelated tasks. Finally, there is no lifetime bound. Nothing ties the work to the block the way a method call ties its callees. The CompletableFuture article’s pipelines improved the composition, allOf and recovery. However, the same hole remains: futures are not children. They are messages in bottles.
The Shape: A Scope That Owns Its Children
The fix is a structure you already understand from a different angle: nested blocks own their contents. try-with-resources owns the resource’s lifetime, a method owns its local variables, and StructuredTaskScope owns its subtasks:
parent virtual thread
|
| fork() fork()
+----- [price] [reviews] subtasks run concurrently
| | |
| (fails!) |
| | |
| +--- policy: ShutdownOnFailure cancels the sibling, NOW
|
+-- join() then throwIfFailed(): the failure surfaces as ONE exception
|
scope exits: NO subtask survives the block. The block IS the lifetime.
The same operation, structured:
// RIGHT: one group, one fate, one exception, zero orphans
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var price = scope.fork(() -> fetchPrice(sku)); // a virtual thread per subtask
var reviews = scope.fork(() -> fetchReviews(sku));
scope.join() // wait for the group (interruptible)
.throwIfFailed(); // any failure: siblings were cancelled, now rethrow
var page = new ProductPage(price.get(), reviews.get());
render(page);
} // exit, normal or exceptional: every subtask is done. Guaranteed by the scope.
The delta from the WRONG version is a contract, not a convention. The scope guarantees that the children end with the block. For example, fetchPrice timing out cancels fetchReviews through the policy instead of orphaning it. Also, the parent sees one exception instead of assembling a story from two Futures. Finally, the code reads as what it is: one operation with two parts, rather than two operations that happen to sit side by side. This is the same insight as try-with-resources from the exceptions article, applied to threads. It delivers correctness by structure instead of correctness by memory.
The Two Policies: Shared Fate, Declared
The scope’s policy is the design decision. The two built-in policies cover the two ways a group can share fate:
| Policy | When it ends the group | The result you take | Use for |
|---|---|---|---|
| ShutdownOnFailure | Any subtask fails: all siblings cancelled immediately | Every subtask’s result, after join and throwIfFailed | All-or-nothing operations: a page built from parts that all must arrive |
| ShutdownOnSuccess | The first subtask succeeds: losers cancelled immediately | The one winner, via result() | Racing replicas: fastest mirror, hedged requests, any-of semantics |
The racing pattern, complete in six lines:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
for (var mirror : mirrors) {
scope.fork(() -> fetch(mirror)); // three mirrors race
}
scope.join(); // one succeeds: the rest are cancelled
var body = scope.result(); // the fastest winner
return parse(body);
}
Compare that with the CompletableFuture article’s anyOf. You will see the same semantics with two properties anyOf cannot give. First, the scope cancels the losers rather than merely ignoring them. Second, a block that cannot leak bounds the whole race. The same holds for ShutdownOnFailure against allOf, plus recovery. Recovery chains say what to do after failure. In contrast, the scope policy says what happens to the rest of the group at failure. That second answer is the one production systems actually need.
Nesting: Groups Inside Groups
The structure composes, which is where the word “structured” pays its rent. A subtask can open its own scope. The tree of scopes then mirrors the tree of the work, with cancellation flowing down the whole subtree:
try (var outer = new StructuredTaskScope.ShutdownOnFailure()) {
var page = outer.fork(() -> { // subtask 1, itself a group
try (var inner = new StructuredTaskScope.ShutdownOnFailure()) {
var price = inner.fork(() -> fetchPrice(sku));
var stock = inner.fork(() -> fetchStock(sku));
inner.join().throwIfFailed();
return new BuyBox(price.get(), stock.get());
}
});
var reviews = outer.fork(() -> fetchReviews(sku)); // subtask 2
outer.join().throwIfFailed();
render(new ProductPage(page.get(), reviews.get()));
}
// if reviews fails, the outer policy cancels page, whose cancellation
// unwinds its inner scope, which cancels price and stock. The whole
// tree ends. One thread dump, one request, zero orphans.
Consider that cancellation cascade. A failure at the top ends a subtree at the bottom through the interrupt mechanism the threads article taught. That is the behavior every hand-rolled attempt at “cancel the request’s work” has been trying to approximate for twenty years. The request becomes a tree of scopes, and cancellation, timeout, and failure all become one mechanical operation instead of per-callsite bookkeeping.
Preview Status of Structured Concurrency in Java
Honesty section, because it matters for production decisions. In Java 21, StructuredTaskScope is a preview feature. Compiling and running requires the –enable-preview flag. Also, the API lives under java.util.concurrent but is subject to change. Meanwhile, the method names you see here, fork, join, throwIfFailed, get, are the Java 21 shapes. The Project Loom pages describe them as evolving toward a smaller, final form. Expect the names to move; do not expect the idea to.
javac --release 21 --enable-preview Main.java
java --enable-preview Main
The practical reading has three parts. First, use it today in services where the team pins the JDK version and accepts preview flags. That covers prototypes, internal tools, and teams that ship with –enable-preview knowingly. Second, use the executor and CompletableFuture patterns from earlier articles where policy allows only final APIs. Third, learn it now regardless. After all, reading the shape of the API, scope, fork, policy, join, is reading the future of the language. Also, the concepts, shared fate and parent-owned lifetime, transfer to every language that has adopted them.
How Real Systems Do This
The industry trajectory is visible in the APIs other ecosystems already shipped. Examples include error groups in Go, task groups in Swift, and nursery scopes in Trio for Python. All encode the same insight Java is formalizing: the code that starts concurrent work should own it. Java’s frameworks are following. By the time structured concurrency leaves preview, expect request handling to be a tree of scopes in every major web framework. Expect timeout middleware to translate directly into scope cancellation. Also expect the orphans problem this article opened with to quietly vanish from the bug tracker.
The orphan that taught me this lesson predates virtual threads entirely. A search service fanned out to a dozen backend indexes, waited with a loop over Futures, and gave up on the first timeout. That was exactly the WRONG shape this article opened with. The code was correct by its own lights, and the dashboards showed green. However, one backend had a habit of hanging. As a result, every incident produced minutes of orphaned queries still burning that backend after the user had received their degraded answer.
The team’s fix was a hand-rolled cancellation scaffold: futures tracked, cancelled in finally blocks, bookkeeping in three places, reviewed forever. It worked, and it was fifty lines of what structured concurrency gives in five. When I first read the StructuredTaskScope draft, I recognized the shape before I finished the code sample. This was the missing language feature, and the fifty lines were the proof. My review rule since then is simple. Any fan-out where subtasks share a caller’s fate is a scope. Likewise, any cancellation implemented by hand is a scope waiting to be written.
Decision Framework
- Are the concurrent subtasks one logical operation, a page, a report, a request, such that partial results are worthless? Shared fate: a scope with ShutdownOnFailure.
- Is the goal the first acceptable answer, racing mirrors or hedged requests? ShutdownOnSuccess, and the scope cancels the losers for you.
- Do the subtasks have independent fate, one can fail and the report still ships, like the link checker’s Results? No shared fate: invokeAll or allOf remains the finished, simpler tool.
- Can a subtask hang indefinitely? Timeouts still apply, virtual-thread friendly ones, and the scope turns one expired deadline into a clean group cancellation.
- Does your production policy allow –enable-preview? If yes, scope it. If no, use the same shape in executor form, with cancellation written by hand and a migration ticket for later.
- Are you nesting concurrent work inside concurrent work? Scopes compose into trees: mirror the shape of the work, and cancellation mirrors it too.
- Is the group large, thousands of subtasks? Virtual threads make that affordable, but shared downstream resources still need semaphores, per the virtual threads article.
When NOT to Use This
- Do not use a preview API where the platform forbids it, conservative enterprise environments, libraries published for wide reuse. The concept transfers, but the flag does not, and library consumers inherit your preview requirement.
- Do not force shared fate where there is none. The link checker wants every outcome collected, and Failed results are data. In contrast, ShutdownOnFailure would cancel the batch on the first dead host, exactly the opposite of the tool’s purpose.
- Do not wrap single subtasks. One fork inside a scope is an expensive function call, while a plain blocking call says the same thing without the machinery.
- Do not treat the scope as a pool. Its fork method starts a fresh virtual thread per subtask by design. So “how big is the scope” is a question from the platform-thread era with no answer here.
- Do not skip join. Reading a subtask’s result before join is a preview-API error today and a conceptually meaningless operation always. After all, the result exists only after the group has settled.
Common Mistakes
- Forking without joining. The scope contract requires join before results. Indeed, the exception at exit is the API refusing to let the block’s guarantee be hollow.
- Choosing ShutdownOnFailure for independent outcomes. Collecting failures as data, the link checker’s Result pattern, dies under an all-or-nothing policy that cancels on the first miss.
- Forgetting that cancellation is interruption. Subtasks must honor the threads article’s interrupt etiquette. Otherwise, the scope cancels nothing and the guarantees silently evaporate.
- Hardcoding the preview API’s method names into muscle memory. The names have already changed across JDK previews, so code to the shape and reread the JEP on each upgrade.
- Unbounded forking against bounded resources. A scope makes subtasks free, not their targets. So the semaphore discipline from the virtual threads article applies inside scopes too.
- Reaching for a scope for sequential work. If nothing overlaps, the block is a costume, and straight-line blocking code remains the most honest form.
- Skipping –enable-preview in deploy scripts. The code compiles with the flag and fails without it. Then the failure surfaces first in CI, or worse, in the environment that skipped CI.
Key Takeaways
- Unstructured concurrency leaks: executor tasks have no relationship to their caller, so failures orphan siblings and cancellations touch nobody.
- StructuredTaskScope makes subtasks children: fork inside try-with-resources, join, and the block’s exit is the group’s end, guaranteed.
- The policy declares the shared fate: ShutdownOnFailure for all-or-nothing, ShutdownOnSuccess for first-winner races, with losers cancelled rather than ignored.
- Scopes nest into trees that mirror the work, and cancellation flows down the tree mechanically instead of by hand-written bookkeeping.
- Structured concurrency in Java targets virtual threads. Cheap per-subtask threads made parent-owned groups viable, and the two features are one story.
- It is preview in Java 21, –enable-preview, API names in motion. So adopt it where flags are acceptable, use executor forms where they are not, and learn the shape regardless.
- Shared fate is the decision. All-or-nothing and first-winner get scopes, while independent outcomes like the link checker’s results stay with invokeAll.
- Interruption etiquette is the foundation. Scope cancellation is the threads article’s interrupt, and subtasks that ignore it void the guarantees.
FAQ
What is structured concurrency in Java?
A model where the parent that starts concurrent subtasks owns them. A StructuredTaskScope groups subtasks so they share the parent’s lifetime. A failure or cancellation ends the whole group, and no subtask can outlive the block that created it. It is preview in Java 21 via JEP 453.
What is StructuredTaskScope in Java?
The API implementing structured concurrency. Its fork method starts subtasks on virtual threads inside a try-with-resources scope, and join waits for the group. Then a policy, ShutdownOnFailure or ShutdownOnSuccess, decides the shared fate. The scope’s exit guarantees every subtask is finished, cancelled, or failed.
What is ShutdownOnFailure in Java?
The all-or-nothing policy. When any subtask fails, the scope immediately interrupts every other subtask, and throwIfFailed after join rethrows the failure as one exception. It suits operations composed of parts that must all succeed, like a page assembled from multiple fetches.
Is structured concurrency stable in Java 21?
No, it is a preview feature. Compilation and runtime require –enable-preview, and the API has changed across JDK previews and may change again before finalization. The concepts, parent-owned groups and shared fate, are stable; the method names are not, so code to the shape and reread the JEP on upgrades.
How is structured concurrency different from ExecutorService?
An ExecutorService runs unrelated tasks with no relationship between caller and work, so failure and cancellation do not propagate. StructuredTaskScope ties subtasks to the parent’s block and applies a group policy, making cancellation, failure handling, and lifetime management structural guarantees instead of manual bookkeeping.
Conclusion
You have seen the complete arc of Part 5’s modern tier. Virtual threads made blocking scalable, and structured concurrency gives that scale the discipline of ordinary code. That means blocks that own their work, failures that end their groups, and cancellation that flows where you need it. The preview flag is a detail; the shape is the future, and it is now readable at a glance.
The next article is the part-closing practice: the concurrent downloader. It upgrades the Part 4 link checker into a concurrent, resource-bounded, gracefully-shutting-down tool. Along the way, it chooses between the executor, CompletableFuture, and virtual-thread forms with reasons you can now state for each.
Own your children, declare their fate, and let the block do the bookkeeping. That is all the structure there ever was.
Last updated on 28 September 2026.
