Java structured concurrency treats a group of concurrent subtasks as one unit of work, so the subtasks start and end inside one code block, and a failure in one of them cancels the others. The API is StructuredTaskScope, which is a preview feature in Java 25 (JEP 505), so we compile and run the code with –enable-preview.
We use structured concurrency when one request needs several independent calls, such as a dashboard that loads a profile, an order count and reward points from three services. The scope waits for all calls, returns their results together, and leaves no thread running after the block.
The following example loads two values at the same time in a scope opened with StructuredTaskScope.open().
String summary;
try (var scope = StructuredTaskScope.open()) {
Subtask<String> profile = scope.fork(() -> loadProfile(7)); // new virtual thread
Subtask<Integer> orders = scope.fork(() -> countOrders(7)); // new virtual thread
scope.join(); // waits for both, throws if one fails
summary = profile.get() + " has " + orders.get() + " orders";
}
String result = summary; // "Lokesh has 3 orders"
Notice that we read the results with Subtask.get() only after join(), and that the try-with-resources block guarantees that both threads have ended. We cover why unstructured code leaks threads, how to enable the preview, the built-in joiners, timeouts, a custom joiner, and how the Java 25 API differs from the Java 21 version.
1. Why Unstructured Code Leaks Threads
With a plain ExecutorService, each submitted task lives on its own. The executor does not know that three tasks belong to the same request, so when one of them fails, nothing stops the others. In the following method, the order call fails right away, the method exits with the exception, and the reward call keeps running in the background with no one to read its result.
static String dashboardUnstructured(ExecutorService executor, int userId) throws Exception {
Future<String> rewards = executor.submit(() -> loadRewards(userId));
Future<Integer> orders = executor.submit(() -> countOrders(userId));
int count = orders.get(); // ExecutionException if orders fail
return count + " orders, " + rewards.get();
}
The same problem appears when the request thread is interrupted, for example because the user closed the page. The subtasks keep running, and a thread dump shows them as unrelated threads, so it is hard to tell which request started them. We could cancel the futures by hand, but every caller has to remember it.
Structured concurrency applies the rule of a method call to threads. A subtask is forked inside a scope, the scope owner waits for it in the same block, and no subtask outlives the block. The scope forms a parent-child tree, so cancelling the scope cancels every subtask in it.

Each fork() starts a new virtual thread by default, so forking a subtask per remote call costs little. When the scope closes, all its threads have finished.
2. Enabling the Preview API in Java 25
Preview code compiles and runs only on the exact Java release it was compiled for, and only with –enable-preview on both javac and java. Without the flag, javac reports that StructuredTaskScope is a preview API and is disabled by default.
# compile and run classes
javac --release 25 --enable-preview -d out Dashboard.java
java --enable-preview -cp out Dashboard
# or run a single source file
java --enable-preview Dashboard.java
In Maven, the maven-compiler-plugin has an enablePreview option. Tests need the same flag through the argLine of the Surefire plugin.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<release>25</release>
<enablePreview>true</enablePreview>
</configuration>
</plugin>
The types Subtask and Joiner are nested in StructuredTaskScope, so we import java.util.concurrent.StructuredTaskScope.Subtask and java.util.concurrent.StructuredTaskScope.Joiner. The feature has changed in almost every release since Java 19.
| Java version | Status | JEP |
|---|---|---|
| 19, 20 | Incubator module jdk.incubator.concurrent | JEP 428, JEP 437 |
| 21 to 24 | Preview, classes ShutdownOnFailure and ShutdownOnSuccess | JEP 453, 462, 480, 499 |
| 25 | Fifth preview, new open() and Joiner API | JEP 505 |
| 26 | Sixth preview, small Joiner changes | JEP 525 |
3. Forking and Joining Subtasks
The life of a scope has four steps. We open the scope, fork one subtask per call, join, and read the results. The thread that opens the scope is its owner, and only the owner may call fork() and join().
- StructuredTaskScope.open() creates a scope that waits for all subtasks and fails if any subtask fails.
- The method fork(Callable) starts the subtask in a new virtual thread and returns a Subtask, whose state is UNAVAILABLE until it completes.
- The method join() waits for the subtasks and either returns the joiner’s result or throws FailedException.
- Subtask.get() returns the result of a successful subtask, and Subtask.state() returns SUCCESS, FAILED or UNAVAILABLE.
The following service methods stand in for remote calls. Each one waits with an interruptible pause(), so a cancelled subtask stops waiting when its thread is interrupted.
static void pause(long ms) throws InterruptedException {
new CountDownLatch(1).await(ms, TimeUnit.MILLISECONDS); // interruptible wait
}
static String loadProfile(int userId) throws InterruptedException {
pause(50);
return "Lokesh";
}
static int countOrders(int userId) throws InterruptedException {
pause(80);
return 3;
}
static String loadRewards(int userId) throws InterruptedException {
pause(120);
return "120 points";
}
Subtask.State before;
Subtask.State after;
String rewards;
try (var scope = StructuredTaskScope.open()) {
Subtask<String> task = scope.fork(() -> loadRewards(7));
before = task.state();
scope.join();
after = task.state();
rewards = task.get();
}
Subtask.State stateBeforeJoin = before; // UNAVAILABLE
Subtask.State stateAfterJoin = after; // SUCCESS
String points = rewards; // "120 points"
The scope checks that we use it in the right order. Calling Subtask.get() before join() throws IllegalStateException with the message join not called, forking after join() throws IllegalStateException, and closing a scope without joining throws IllegalStateException with Owner did not join after forking.
4. Cancelling Siblings When a Subtask Fails
The default joiner of open() is Joiner.awaitAllSuccessfulOrThrow(). When a subtask fails, it cancels the scope, which interrupts the threads of the unfinished subtasks, and join() throws FailedException with the subtask’s exception as the cause. FailedException is unchecked.
In the following example, the order service is down. The reward call would take two seconds, but the scope interrupts it as soon as the order call fails, and we record that interruption in a flag.
static int countOrdersDown(int userId) {
throw new IllegalStateException("orders service down");
}
static String loadRewardsSlowly(int userId, AtomicBoolean interrupted) throws InterruptedException {
try {
pause(2_000);
return "120 points";
} catch (InterruptedException e) {
interrupted.set(true);
throw e;
}
}
AtomicBoolean interrupted = new AtomicBoolean();
String outcome;
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> loadRewardsSlowly(7, interrupted));
scope.fork(() -> countOrdersDown(7));
scope.join();
outcome = "ok";
} catch (StructuredTaskScope.FailedException e) {
outcome = e.getCause().getMessage();
}
String failure = outcome; // "orders service down"
boolean rewardsCancelled = interrupted.get(); // true
Compare that with the ExecutorService version in section 1. The failed request returns at once, the slow call stops, and when the try block ends, no thread from the scope is still running.
5. Choosing a Joiner
A Joiner decides two things, i.e. when the scope is cancelled and what join() returns. We pass it to StructuredTaskScope.open(joiner), and Java 25 offers five factory methods.
| Joiner factory | join() returns | Cancels the scope when | Typical use |
|---|---|---|---|
| awaitAllSuccessfulOrThrow() | null (Void) | Any subtask fails | Default of open(), results read from each Subtask |
| allSuccessfulOrThrow() | Stream<Subtask<T>> | Any subtask fails | Many subtasks of the same type |
| anySuccessfulResultOrThrow() | The first successful result | Any subtask succeeds | Racing replicas or mirrors |
| awaitAll() | null (Void) | Never | Fire-and-wait tasks where each failure is handled alone |
| allUntil(predicate) | Stream<Subtask<T>> | The predicate returns true | Custom stop condition |
Say a download page offers a file from two mirrors, and we want whichever answers first. The joiner from anySuccessfulResultOrThrow() returns the first successful result and cancels the slower subtask. If every subtask fails, join() throws FailedException.
String fastest;
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> { pause(300); return "mirror-a"; });
scope.fork(() -> { pause(20); return "mirror-b"; });
fastest = scope.join();
}
String winner = fastest; // "mirror-b"
When every subtask returns the same type, allSuccessfulOrThrow() saves us from keeping each Subtask in a variable. The stream lists the subtasks in the order they were forked.
List<Integer> stock;
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
for (int warehouse = 1; warehouse <= 3; warehouse++) {
int id = warehouse;
scope.fork(() -> id * 10);
}
stock = scope.join().map(Subtask::get).toList();
}
List<Integer> perWarehouse = stock; // [10, 20, 30]
6. Setting a Timeout
A request often has a time budget, and a scope can enforce it. We pass a function that changes the scope’s configuration to the second open() method and call withTimeout() on it. If the subtasks are not done in time, the scope is cancelled and join() throws StructuredTaskScope.TimeoutException, which is also unchecked.
String status;
try (var scope = StructuredTaskScope.open(Joiner.<String>awaitAllSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(100)))) {
scope.fork(() -> { pause(1_000); return "late report"; });
scope.join();
status = "done";
} catch (StructuredTaskScope.TimeoutException e) {
status = "timed out";
}
String report = status; // "timed out"
The configuration function also accepts withName(), which names the scope in thread dumps, and withThreadFactory(), which replaces the default virtual thread factory, for example with named threads.
7. Writing a Custom Joiner
When no built-in joiner fits, we implement the Joiner interface. The scope calls onComplete() for every finished subtask, and a return value of true cancels the scope. The method result() produces the value of join(). The following joiner collects the successful results and ignores failures, which suits a search page that shows whatever partner sites answered.
class SuccessfulResults<T> implements Joiner<T, List<T>> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(Subtask<? extends T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false; // never cancel the scope
}
@Override
public List<T> result() {
return List.copyOf(results);
}
}
List<String> offers;
try (var scope = StructuredTaskScope.open(new SuccessfulResults<String>())) {
scope.fork(() -> "hotel-a: 90");
scope.fork(() -> { throw new IllegalStateException("hotel-b down"); });
scope.fork(() -> "hotel-c: 85");
offers = scope.join();
}
int offerCount = offers.size(); // 2
The onComplete() method runs in the subtask threads, possibly at the same time, so the joiner stores results in a thread-safe ConcurrentLinkedQueue.
8. Moving from the Java 21 API to Java 25
Code written for Java 21 to 24 does not compile on Java 25, because JEP 505 replaced the public constructors and subclasses with static open() methods and joiners. Most changes are mechanical.
| Java 21 to 24 | Java 25 |
|---|---|
| new StructuredTaskScope.ShutdownOnFailure() | StructuredTaskScope.open() |
| new StructuredTaskScope.ShutdownOnSuccess<T>() | open(Joiner.anySuccessfulResultOrThrow()) |
| scope.join(); scope.throwIfFailed(); | scope.join(), which throws FailedException |
| scope.result() | The return value of join() |
| scope.joinUntil(deadline) | open(joiner, cf -> cf.withTimeout(duration)) |
| Subclass of StructuredTaskScope with handleComplete() | Implementation of Joiner with onComplete() |
Java 26 continues the preview with JEP 525. It renames anySuccessfulResultOrThrow() to anySuccessfulOrThrow(), makes allSuccessfulOrThrow() return a list of results, and adds an onTimeout() method to Joiner, so preview code needs small edits with each JDK upgrade.
9. Structured Concurrency vs ExecutorService and CompletableFuture
All three run tasks at the same time, but they differ in who owns the threads and what happens on failure. Structured concurrency is the strictest of the three, which is why it suits request-scoped fan-out.
| Aspect | ExecutorService | CompletableFuture | StructuredTaskScope |
|---|---|---|---|
| Thread lifetime | Until the executor shuts down | Until the stage completes | Ends with the scope block |
| Failure of one task | Others keep running | Others keep running unless we cancel them | Others are cancelled |
| Interrupt of the caller | Not propagated | Not propagated | Cancels all subtasks |
| Code style | Blocking get() | Callbacks and chained stages | Blocking, sequential |
| Status in Java 25 | Final | Final | Preview |
For pipelines of dependent steps, CompletableFuture remains a good fit. For long-lived worker pools, we keep an executor such as newVirtualThreadPerTaskExecutor().
10. Structured Concurrency FAQs
Preview status, thread types, scoped values, nesting and the removed classes decide whether the API is ready for a given project.
10.1. Is structured concurrency final in Java 25?
No. It is the fifth preview in Java 25 and still a preview in Java 26, so it needs –enable-preview. We can use it in production only if we accept rebuilding the code for each new JDK.
10.2. Does StructuredTaskScope always use virtual threads?
By default, yes. The default thread factory creates one unnamed virtual thread per subtask. With withThreadFactory(), we can pass another factory, including one for platform threads.
10.3. Can subtasks read scoped values of the parent?
Yes. Subtasks inherit the scoped values bound in the owner thread when it opened the scope. Threads started in other ways, such as through an ExecutorService, do not inherit them.
10.4. Can we nest scopes?
Yes. A subtask can open its own scope, which forms a tree. Cancelling the outer scope interrupts the subtask, which in turn cancels its inner scope.
10.5. What replaced ShutdownOnFailure and ShutdownOnSuccess?
StructuredTaskScope.open() replaces ShutdownOnFailure, and open(Joiner.anySuccessfulResultOrThrow()) replaces ShutdownOnSuccess in Java 25, as listed in section 8.
11. Conclusion
Structured concurrency ties the threads of a request to one block. We open a scope with StructuredTaskScope.open(), fork each call, join, and read the results, and the scope cancels the remaining subtasks when one fails or when the time budget runs out.
In Java 25, joiners define the policy, from waiting for all subtasks to taking the first result, and a custom joiner covers the rest. The API is still a preview, so it needs –enable-preview and small changes with each release. Other concurrency tools are grouped in the Java concurrency guide.
12. References
- JEP 505, Structured Concurrency (Fifth Preview)
- JEP 525, Structured Concurrency (Sixth Preview)
- StructuredTaskScope Javadoc (Java 25)
- StructuredTaskScope.Joiner Javadoc (Java 25)
- JEP 12, Preview Features
Happy Learning !!