import java.util.concurrent.StructuredTaskScope;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<User> userTask =
scope.fork(() -> fetchUser(userId));
StructuredTaskScope.Subtask<List<Order>> ordersTask =
scope.fork(() -> fetchOrders(userId));
scope.join()
.throwIfFailed();
return new Dashboard(userTask.get(), ordersTask.get());
}
scope.fork() returns a Subtask handle. You read subtask.get() only after the join — the API enforces this at runtime, so you can't accidentally read a result before its task finishes.
ShutdownOnFailure — Fail Fast
ShutdownOnFailure implements a simple policy: the first task to fail cancels all the others. This is the policy you want for most parallel-fetch patterns where all results are required.
record ProductPage(Product product, List<Review> reviews, int stockCount) {}
ProductPage loadProductPage(String productId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var productTask = scope.fork(() -> productService.get(productId));
var reviewsTask = scope.fork(() -> reviewService.getTop(productId, 5));
var inventoryTask = scope.fork(() -> inventoryService.getStock(productId));
scope.join().throwIfFailed();
return new ProductPage(
productTask.get(),
reviewsTask.get(),
inventoryTask.get()
);
}
}
If reviewService.getTop() throws, the scope immediately sends an interrupt to the other two tasks, waits for them to stop, then rethrows the original exception from .throwIfFailed(). No leaked threads, no partially-assembled result.
ShutdownOnSuccess — First Result Wins
ShutdownOnSuccess is the opposite policy: the first task to succeed cancels all the others. Use this for redundant calls where you want the fastest response — hedged requests, fallback endpoints, or racing two LLM providers.
String getAiResponse(String prompt) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> claudeClient.complete(prompt));
scope.fork(() -> fallbackClient.complete(prompt));
scope.join();
return scope.result();
}
}
scope.result() returns the first successful value. If both tasks fail, it throws a ExecutionException wrapping the last failure. This pattern is valuable when tail latency matters — the slow response gets cancelled the moment a fast one arrives.
Custom Policies
The two built-in policies cover most cases. For anything else, extend StructuredTaskScope directly and override handleComplete:
class QuorumScope<T> extends StructuredTaskScope<T> {
private final int quorum;
private final List<T> results = new CopyOnWriteArrayList<>();
QuorumScope(int quorum) { this.quorum = quorum; }
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
if (results.size() >= quorum) shutdown();
}
}
public List<T> results() {
ensureOwnerAndJoined();
return List.copyOf(results);
}
}
try (var scope = new QuorumScope<UserData>(2)) {
scope.fork(() -> replicaA.read(userId));
scope.fork(() -> replicaB.read(userId));
scope.fork(() -> replicaC.read(userId));
scope.join();
return scope.results();
}
ensureOwnerAndJoined() is provided by the base class and throws if called before join() completes — it enforces the contract that results are only readable after the scope is done.
What You Get for Free: Cancellation and Scoped Values
Cancellation propagates automatically. When a scope shuts down, it sends an interrupt to every forked task. Tasks that are blocking on I/O (virtual threads parked on socket reads, for example) wake up and exit cleanly. You don't write any cancellation logic — you just let the scope do it.
Scoped Values flow into subtasks automatically. Scoped Values (also GA in Java 24, JEP 487) are the replacement for ThreadLocal in a virtual thread world. Any scoped value bound in the parent scope is readable inside every forked subtask:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.runWhere(REQUEST_ID, traceId, () -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
logger.info("[{}] Fetching user", REQUEST_ID.get());
return userService.get(userId);
});
scope.join().throwIfFailed();
}
});
No more threading a context object through every method signature just to carry a request ID into parallel work.
Before vs After
The thread dump improvement alone is significant in production: with structured concurrency, a thread dump shows the scope hierarchy, making it immediately clear which tasks belong to which request.
Putting It Together: Parallel AI Calls in a Spring Boot Endpoint
Spring Boot 3.2+ enables virtual threads via spring.threads.virtual.enabled=true. With that in place, every request runs on a virtual thread, and StructuredTaskScope fits in naturally:
@RestController
@RequiredArgsConstructor
public class InsightController {
private final UserService userService;
private final ClaudeService claudeService;
private final MetricsService metricsService;
@GetMapping("/insights/{userId}")
public InsightResponse getInsights(@PathVariable String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> userService.getProfile(userId));
var aiTask = scope.fork(() -> claudeService.generateInsight(userId));
var metricsTask = scope.fork(() -> metricsService.getSummary(userId));
scope.join().throwIfFailed();
return new InsightResponse(
userTask.get(),
aiTask.get(),
metricsTask.get()
);
}
}
}
Total latency = max(userService, claudeService, metricsService) instead of the sum. If the AI call takes 800 ms and the other two take 50 ms each, the endpoint returns in ~800 ms, not ~900 ms.
Maven setup for Java 24:
<properties>
<java.version>24</java.version>
<maven.compiler.source>24</maven.compiler.source>
<maven.compiler.target>24</maven.compiler.target>
</properties>
application.properties for virtual threads:
spring.threads.virtual.enabled=true
Key Takeaways
- Use
ShutdownOnFailure when all parallel results are required — the default for most parallel-fetch patterns.
- Use
ShutdownOnSuccess when you're racing for the fastest response — hedged requests, fallback providers, redundant caches.
- Extend
StructuredTaskScope for custom join policies — quorum reads, majority-wins, collect-N.
- The lifetime rule is the feature: a subtask cannot outlive its scope. No thread leaks, no orphaned work.
- Scoped Values replace
ThreadLocal for passing context into forked tasks — no explicit threading of parameters.
- Java 24 required for GA. Java 21–23 need
--enable-preview. For Spring Boot, pair with spring.threads.virtual.enabled=true.
If you're already using virtual threads, Structured Concurrency is the natural next step. It takes the throughput wins from virtual threads and adds the coordination layer that makes concurrent code safe to write and straightforward to debug.
Further reading: