Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Most concurrency bugs come from missing lifecycle, not missing parallelism. A scope makes task lifetime visible in code.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
All code in this series targets Java 21 preview APIs. Part 9 covers migration to Java 25.
Most Java concurrency explanations start with speed: more threads, cheaper threads, better throughput, higher concurrency. That story matters, especially with virtual threads, but it is not the first idea to understand when learning structured concurrency.
The first idea is lifetime.
When a request starts three pieces of work, who owns those three pieces of work? Who waits for them? Who observes their failures? Who cancels the remaining work if one branch fails? Who can say, with confidence, that all work started for this request has finished before the request handler returns?
Those questions are what structured concurrency is about.
This series is learning material. It is not a production incident report, and it is not a claim that a preview API should be dropped blindly into every service. The goal is narrower and more useful: use small runnable examples to understand what structured scopes make visible.
Virtual threads make it practical to write blocking code again. Structured concurrency gives that blocking code a shape. A virtual thread can make a task cheap to park; a structured scope makes the task's lifetime explicit.
Take a request that needs three independent values:
In older Java code, the first version often looks like a thread pool plus futures:
List<Future<String>> futures = new ArrayList<>();
futures.add(executor.submit(() -> fetchUser()));
futures.add(executor.submit(() -> fetchAccount()));
futures.add(executor.submit(() -> fetchNotifications()));
String user = futures.get(0).get();
String account = futures.get(1).get();
String notifications = futures.get(2).get();
return combine(user, account, notifications);
On the happy path this is easy enough to read. Three tasks start, three results come back, and the response is assembled.
The problem is not the happy path. The problem is every other path.
What happens if fetchAccount() throws? The exception appears when you call futures.get(1).get(), but the other submitted tasks do not belong to that exception. They belong to the executor. Unless you explicitly cancel them, they keep running.
What happens if the outer request times out after 500ms? The caller has left, but the submitted tasks still live in the executor. They may finish later and produce results nobody will use. If they call other simulated dependencies, that extra work still happens.
What happens if one task fails quickly and another task sleeps for two seconds? You now need policy code:
try {
String user = userFuture.get();
String account = accountFuture.get();
String notifications = notificationsFuture.get();
return combine(user, account, notifications);
} catch (Exception e) {
userFuture.cancel(true);
accountFuture.cancel(true);
notificationsFuture.cancel(true);
throw e;
}
That is not impossible code. It is just easy to get wrong, and it tends to be repeated differently at every fan-out site.
The deeper issue is that the relationship between the parent operation and the child tasks is not represented by the structure of the code. The tasks were submitted to a pool. The pool is not the request. The request may be done while the pool is still running the work.
That is unstructured concurrency: related tasks exist, but their relationship is maintained by convention.
CompletableFuture improves the composition story:
CompletableFuture<String> user =
CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> account =
CompletableFuture.supplyAsync(() -> fetchAccount());
CompletableFuture<String> notifications =
CompletableFuture.supplyAsync(() -> fetchNotifications());
CompletableFuture.allOf(user, account, notifications).join();
return combine(user.get(), account.get(), notifications.get());
This is more expressive than manually walking a list of futures. You can chain, combine, transform, and recover.
But the ownership question is still not obvious from the code.
If a future deep in the chain starts more asynchronous work, does the original request wait for it? If one sibling fails, should the other siblings stop? If the caller gives up, which futures get cancelled? If a callback catches an exception and returns a fallback, was that fallback policy intended for the whole request or only for that one branch?
You can answer all of those questions with CompletableFuture, but the answers are spread across callbacks and policy decisions. The code can become correct, but the correctness is not local.
Structured concurrency is an attempt to make that correctness local.
In Java 21 preview, the central type is StructuredTaskScope.
A scope is a parent for related subtasks. You open the scope, fork subtasks inside it, join the scope, handle failure according to the scope policy, read the results, and then close the scope.
The important part is not just the API. It is the block shape:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser());
Subtask<String> account = scope.fork(() -> fetchAccount());
Subtask<String> notifications = scope.fork(() -> fetchNotifications());
scope.join();
scope.throwIfFailed();
return combine(user.get(), account.get(), notifications.get());
}
The try block is the lifetime boundary. The subtasks are not just floating around in an executor anymore. They are children of this scope.
scope.fork(...) starts related work.
scope.join() waits for the scope's subtasks to finish, fail, or be cancelled according to the scope policy.
scope.throwIfFailed() turns a subtask failure into an exception in the parent thread.
Only after that do we call get() on the subtasks, then the scope closes.
The ordering matters: fork first, then join, then apply the failure policy, then read results, then let the scope close.
Once you learn that rhythm, structured concurrency becomes much easier to review. You can look at a block of code and ask: are all the tasks for this operation inside this scope? Is join() called before reading results? Is the failure policy visible? Does the scope close before the method returns?
Those are simpler questions than reconstructing a graph of futures and callbacks.
ShutdownOnFailure is the easiest policy to learn first.
Its rule is: if one subtask fails, the scope shuts down the remaining siblings.
That maps naturally to all-or-nothing fan-out. If a page needs user, account, and notification data to produce a complete response, then one failed required dependency means the whole operation fails. There is no point letting the other required branches continue if their results will be discarded.
With plain futures, you have to remember to cancel the siblings. With a ShutdownOnFailure scope, that policy is part of the scope.
This does not mean Java forcibly kills running code. Java cancellation is cooperative. If a task is sleeping, blocked in interruptible I/O, or checking interruption correctly, it can stop promptly. If a task swallows InterruptedException or keeps running CPU code without checking interruption, the scope cannot magically make it well-behaved.
That is an important teaching point: structured concurrency improves ownership and propagation, but task code still needs to respect cancellation.
The benefit is that the parent no longer has to rediscover which siblings exist and remember to cancel each one manually. The siblings are the scope's children.
The opposite policy is ShutdownOnSuccess.
Its rule is: once one subtask succeeds, the scope can shut down the remaining siblings.
That fits a different shape of problem. Imagine asking multiple caches for the same value:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> getFromL1Cache(key));
scope.fork(() -> getFromL2Cache(key));
scope.fork(() -> getFromDatabase(key));
scope.join();
return scope.result();
}
If the first cache has the value, the slower branches are no longer needed. If the first cache misses but the second succeeds, the database branch can stop. The parent wants one successful answer, not every answer.
Again, the useful part is not only that this can be done. You can build a race with CompletableFuture.anyOf. The useful part is that the policy is attached to the lifetime boundary. The code says: these tasks are siblings, and the first success wins.
That makes the shape teachable.
This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. The measurements below were generated from the current Java 25 code. The Java 21 preview syntax shown here remains valid for learning purposes, and Part 9 covers the migration details.
The clean service example has an aggregate operation with four simulated calls:
When I ran that scoped aggregate locally, it completed in 206ms.
That number is not presented as a production benchmark. It is a sanity check for the mental model. Four sequential sleeps would add up to 530ms. Four sibling tasks in one scope finish near the slowest sibling, which is the 200ms analytics branch.
The same run showed the other shapes behaving as expected:
The lesson is not "structured concurrency is faster." The lesson is that the elapsed time matches the structure of the work. Sibling tasks run as siblings, and the parent waits at the scope boundary.
For the Article 1 examples, the most relevant companion code is StructuredMicroservice, BusinessService, and VirtualThreadMicroservice. The scripts used to reproduce the checks are test_clean_structured.sh and test_structured_concurrency.sh.
Structured concurrency does not remove every concurrency bug. It changes where you look.
The first thing it exposes is work that outlives the operation that started it. In unstructured code, the parent may submit tasks and then return early. The tasks are still running somewhere else. In a structured scope, the parent block cannot simply exit while its children are still unsettled. The scope forces a join point. That is the core guarantee.
It also makes sibling failure harder to hide. If work is scattered across callbacks, executors, or detached futures, a failure can show up far away from the code that made the orchestration decision. With ShutdownOnFailure, the failure is surfaced at the scope boundary. The parent sees it where the related work was launched. That makes review easier. You do not have to ask whether some future chain logs and swallows the failure three callbacks later. You can look for the scope policy.
Finally, structured scopes force you to name cancellation policy instead of leaving it as an afterthought. Every fan-out has a cancellation policy, even when the code does not name it. The policy might be "let everything run." It might be "try to cancel siblings manually." It might be "return partial results." It might be "first success wins."
Structured concurrency pushes that policy into the shape of the code. ShutdownOnFailure and ShutdownOnSuccess are not the only possible policies, but they teach the important habit: related concurrent work should have an explicit lifecycle policy.
For a learning series, the boundaries matter as much as the benefits.
Structured concurrency does not make CPU-bound work ignore core count. If you fork 100 CPU-heavy tasks on a machine with far fewer cores, the scheduler still has to share CPU time.
Structured concurrency does not make bad cancellation behavior disappear. If a task ignores interruption, it can still delay shutdown.
Structured concurrency does not turn preview APIs into final APIs. The Java 21 preview API changed by Java 25. That is why this series keeps Articles 1-8 on Java 21 syntax and uses Article 9 as the migration bridge.
Structured concurrency also does not replace careful design of partial results. Sometimes all siblings are required. Sometimes partial data is acceptable. Sometimes first success is enough. Those are application-level decisions. The scope gives those decisions a place to live.
When you see a structured scope, read it like a small tree:
parent operation
├── subtask A
├── subtask B
└── subtask C
Then ask five questions:
That review checklist is the practical value of the model. Structured concurrency does not just run tasks. It gives the task tree a readable boundary.
The companion repository at github.com/salgotraja/project-loom has runnable examples for every post in this series. The README covers build setup and the scripts used to reproduce the checks.
This post is the foundation: scope ownership, sibling tasks, failure propagation, and cancellation policy.
Part 2 moves from basic ownership to timeouts and partial results. That is where the design choice becomes more interesting. If a deadline is missed, should the caller receive an error, the completed subset, or a fallback? Structured concurrency does not choose that for you, but it gives you a clear place to make the choice.