Structured Concurrency is GA in Java 24 (JEP 499, released March 2025). It was in preview from Java 21–23 — if you're on an earlier version, add --enable-preview to your compiler and JVM flags. On Java 24+, no flag is needed.
Virtual threads changed how Java handles I/O-bound concurrency — instead of blocking a platform thread, a virtual thread parks cheaply and yields the carrier thread to other work. If you haven't read that story yet, start with the Java 21 Virtual Threads guide.
But virtual threads left one problem unsolved: how do you coordinate a group of concurrent tasks cleanly? Structured Concurrency is the answer. It is the API that makes writing parallel code feel like writing sequential code — and it is finally production-ready in Java 24.
The Problem Virtual Threads Didn't Solve
Imagine fetching a user profile and their recent orders in parallel, then combining the results:
// The old way — with ExecutorService
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userFuture = executor.submit(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
try {
User user = userFuture.get();
List<Order> orders = ordersFuture.get();
return new Dashboard(user, orders);
} catch (ExecutionException e) {
userFuture.cancel(true); // easy to forget
ordersFuture.cancel(true); // easy to forget
throw e;
}
Three problems with this:
- Thread leaks on partial failure. If
fetchUserthrows, theordersFuturekeeps running unless you explicitly cancel it. Forget one.cancel()call and you leak a thread. - Error propagation is manual. You unwrap
ExecutionException, re-throw, hope nothing gets swallowed. - No lifetime guarantee. There's nothing stopping a task from outliving the code that created it. Debugging becomes archaeology.
Structured Concurrency solves all three with one rule: a subtask cannot outlive the scope that forked it.
What Structured Concurrency Is
The core mental model is a strict parent-child lifetime:
When the scope closes — either normally or via exception — every forked task has either completed or been cancelled. No loose threads, no orphaned work.
StructuredTaskScope Basics
The API uses try-with-resources. You open a scope, fork tasks into it, join, then read results:
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() // wait for both tasks to complete
.throwIfFailed(); // rethrow the first exception if any task failed
// Both tasks succeeded — safe to read results
return new Dashboard(userTask.get(), ordersTask.get());
}
// Scope closes here: all tasks are done or cancelled, guaranteed
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)); // primary
scope.fork(() -> fallbackClient.complete(prompt)); // backup
scope.join(); // waits until one succeeds (or both fail)
return scope.result(); // the winning response
}
}
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:
// Collect N successful results, then cancel the rest
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(); // cancel remaining
}
}
public List<T> results() {
ensureOwnerAndJoined(); // enforced by the API
return List.copyOf(results);
}
}
// Usage: read from 2 out of 3 replicas, accept whichever 2 respond first
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(); // at least 2 results, or fewer if only 1-2 replicas responded
}
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();
// In a request handler:
ScopedValue.runWhere(REQUEST_ID, traceId, () -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
// REQUEST_ID.get() works here — no explicit passing needed
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
| ExecutorService + CompletableFuture | StructuredTaskScope | |
|---|---|---|
| Thread leak on failure | Must manually cancel all futures | Automatic — scope cancels on close |
| Error propagation | Unwrap ExecutionException manually |
.throwIfFailed() one call |
| Task lifetime guarantee | None — tasks can outlive the caller | Enforced by the scope |
| Result reads before join | Possible (race condition) | Blocked by API at runtime |
| Code lines for parallel fetch + error handling | ~20–30 lines | ~8–10 lines |
| Debugger / thread dump readability | Flat list of threads | Subtask tree matches call structure |
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()) {
// Three independent calls — all run in parallel
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()
);
}
// All three tasks done or cancelled before this line — no exceptions possible here
}
}
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
ShutdownOnFailurewhen all parallel results are required — the default for most parallel-fetch patterns. - Use
ShutdownOnSuccesswhen you're racing for the fastest response — hedged requests, fallback providers, redundant caches. - Extend
StructuredTaskScopefor 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
ThreadLocalfor 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 withspring.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: