Java Structured Concurrency: Safe Concurrent Programming (2026)
Concurrent code has always been hard to reason about. CompletableFuture chains work, but cancellation is manual, error handling is scattered, and debugging thread leaks is painful. Java's Structured Concurrency API — previewed in JDK 21 and finalized in JDK 25 — brings discipline to multi-threaded code by tying the lifetime of concurrent tasks to a lexical scope.
The Problem with CompletableFuture
CompletableFuture is powerful but has three chronic problems in production code:
- Cancellation is not automatic. If one of your three parallel calls fails, the other two keep running and consuming resources unless you manually cancel them.
- Threads can outlive their context. A request is cancelled by the client, but your background futures are still running, possibly writing to a response that is already closed.
- Error handling is non-obvious. Exceptions are wrapped in
ExecutionException, chained withexceptionally(), and combining multiple failures requires careful orchestration.
// CompletableFuture: parallel calls with manual cancellation — fragile
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(id));
CompletableFuture.allOf(userFuture, orderFuture)
.thenApply(v -> buildResponse(userFuture.join(), orderFuture.join()))
.exceptionally(ex -> {
userFuture.cancel(true); // manual cleanup — easy to forget
orderFuture.cancel(true);
throw new RuntimeException(ex);
});
// If fetchUser throws, fetchOrder keeps running until it finishes
StructuredTaskScope API Overview
StructuredTaskScope is the base class. You open a scope with try-with-resources, fork tasks into it, join, and then read results. The critical guarantee: no task can outlive the scope. When the scope closes, all tasks are either completed or cancelled.
// Basic pattern
try (var scope = new StructuredTaskScope<String>()) {
Subtask<String> t1 = scope.fork(() -> callServiceA());
Subtask<String> t2 = scope.fork(() -> callServiceB());
scope.join(); // waits for both to complete
// Check state and get results
if (t1.state() == Subtask.State.SUCCESS) {
String result = t1.get();
}
if (t2.state() == Subtask.State.FAILED) {
Throwable ex = t2.exception();
}
} // scope closes — all tasks are done, no leaks
The two built-in policies are ShutdownOnFailure and ShutdownOnSuccess. Both extend StructuredTaskScope with specific shutdown logic.
ShutdownOnFailure Pattern
ShutdownOnFailure is the fail-fast pattern: as soon as any subtask fails, the scope shuts down and signals all other subtasks to stop. This is the right choice when you need all results to proceed.
// Real example: checkout flow requiring user, inventory, and payment data
public CheckoutResponse processCheckout(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserProfile> userTask =
scope.fork(() -> userService.getProfile(orderId));
Subtask<InventoryStatus> inventoryTask =
scope.fork(() -> inventoryService.check(orderId));
Subtask<PaymentMethod> paymentTask =
scope.fork(() -> paymentService.getMethod(orderId));
scope.join(); // block until all complete or first fails
scope.throwIfFailed(); // if any failed: throws first exception, others cancelled
// All three succeeded — safe to call .get()
return new CheckoutResponse(
userTask.get(),
inventoryTask.get(),
paymentTask.get()
);
}
}
scope.throwIfFailed() rethrows the first exception as the cause. If you want to transform it, use scope.throwIfFailed(ex -> new ServiceException("Checkout failed", ex)) — the overload accepts a Function<Throwable, X>.
ShutdownOnSuccess Pattern
ShutdownOnSuccess is the race pattern: multiple tasks compete and the first to succeed wins. The others are cancelled immediately. Use this for latency hedging — sending the same query to a primary and a replica, or to two different cache tiers.
// Race pattern: try cache first, then DB, then remote — first response wins
public String lookupProduct(String sku) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> redisCache.get(sku)); // usually fastest
scope.fork(() -> localDatabase.query(sku)); // fallback
scope.fork(() -> catalogService.fetch(sku)); // slowest, most authoritative
scope.join();
return scope.result(); // result of first task to succeed
}
}
// With timeout: don't wait more than 200ms total
public String lookupWithDeadline(String sku) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> redisCache.get(sku));
scope.fork(() -> localDatabase.query(sku));
scope.joinUntil(Instant.now().plusMillis(200));
try {
return scope.result();
} catch (IllegalStateException e) {
return "default-product"; // all timed out or failed
}
}
}
Scope Hierarchy and Task Relationships
Scopes can be nested. A parent scope can fork tasks that internally open their own scopes. The lifetime guarantee is recursive — inner scopes must complete before outer scopes do.
// Nested scopes: outer scope fans out; each subtask opens its own inner scope
public DashboardData loadDashboard(long userId) throws Exception {
try (var outerScope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserSummary> userTask = outerScope.fork(() -> {
// inner scope for user data
try (var innerScope = new StructuredTaskScope.ShutdownOnFailure()) {
var profileTask = innerScope.fork(() -> fetchProfile(userId));
var prefsTask = innerScope.fork(() -> fetchPreferences(userId));
innerScope.join().throwIfFailed();
return new UserSummary(profileTask.get(), prefsTask.get());
}
});
Subtask<List<Notification>> notifTask =
outerScope.fork(() -> fetchNotifications(userId));
outerScope.join().throwIfFailed();
return new DashboardData(userTask.get(), notifTask.get());
}
}
Error Propagation Model
In ShutdownOnFailure: exceptions from subtasks are collected. throwIfFailed() rethrows the first one. Subsequent exceptions are suppressed and attached as suppressed exceptions — you can retrieve them from the caught exception.
In ShutdownOnSuccess: exceptions from individual subtasks are ignored as long as at least one succeeds. If all tasks fail, scope.result() throws an ExecutionException.
Interrupted tasks receive an interrupt signal. They should respect Thread.currentThread().isInterrupted() or use interruptible blocking operations (which is the default for JDK I/O).
Timeout Handling
Both scope types support deadlines via joinUntil(Instant deadline). If the deadline passes before all tasks finish, the scope shuts down and unfinished tasks are cancelled.
// Hard 500ms deadline on all subtasks combined
public ApiResponse callWithTimeout(String param) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var t1 = scope.fork(() -> serviceA.call(param));
var t2 = scope.fork(() -> serviceB.call(param));
// joinUntil returns normally or throws TimeoutException
try {
scope.joinUntil(Instant.now().plusMillis(500));
} catch (TimeoutException e) {
// tasks still running are being cancelled right now
throw new ServiceTimeoutException("Request timed out after 500ms");
}
scope.throwIfFailed();
return new ApiResponse(t1.get(), t2.get());
}
}
Combining with Virtual Threads
StructuredTaskScope forks tasks as virtual threads by default. The combination is the natural fit — scopes provide structure and lifetime management, virtual threads provide cheap concurrency for I/O-bound subtasks.
// 1000 parallel database lookups — each in its own virtual thread, all within one scope
public List<ProductDetail> batchLookup(List<String> skus) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<Subtask<ProductDetail>> tasks = skus.stream()
.map(sku -> scope.fork(() -> db.findProduct(sku)))
.toList();
scope.join().throwIfFailed();
return tasks.stream()
.map(Subtask::get)
.toList();
}
}
StructuredTaskScope creates virtual threads internally since JDK 21. You do not need to configure an executor — each fork() call spawns a new virtual thread. This is safe to do even for tens of thousands of subtasks.
Comparison Table: CompletableFuture vs StructuredTaskScope vs ForkJoinPool
| Feature | CompletableFuture | StructuredTaskScope | ForkJoinPool |
|---|---|---|---|
| Auto cancellation on failure | No (manual) | Yes | No |
| Scope-bound task lifetime | No | Yes | No |
| Error propagation | Wrapped in ExecutionException | Direct exception propagation | Wrapped |
| Timeout support | orTimeout() / completeOnTimeout() | joinUntil(Instant) | Manual |
| Debuggability (thread dumps) | Poor (anonymous threads) | Good (structured hierarchy) | Moderate |
| Virtual thread integration | Requires custom executor | Built-in | Separate concern |
| Learning curve | High (chains, combinators) | Low (sequential-style code) | High (work-stealing details) |
| Best for | Async pipelines, reactive-adjacent | Fan-out/fan-in, race patterns | CPU-bound parallel work |
FAQ
- Is StructuredTaskScope available in JDK 21?
- Yes, as a preview API. You need to compile and run with
--enable-preview. In JDK 25 it is a standard finalized API and no preview flag is required. The API changed slightly between preview iterations — check the JDK release notes for your version. - Can I use StructuredTaskScope with Spring?
- Yes. Spring does not need special support — you can use
StructuredTaskScopein any Spring service bean. Spring Boot 3.2+'s virtual thread support means the request thread is already a virtual thread, and your scope will fork additional virtual threads from it. - What happens if I call scope.fork() after scope.join()?
- It throws
IllegalStateException. You must fork all tasks before calling join. The design is intentional — all forks must be declared upfront so the scope knows what it is waiting for. - How is StructuredTaskScope different from ExecutorService.invokeAll()?
invokeAll()waits for all tasks and returns futures, but it does not cancel running tasks if one fails, and there is no scope-based lifetime guarantee.StructuredTaskScopeprovides automatic cancellation, structured lifetime, and better exception handling.- Can subtasks themselves fork more tasks?
- Yes — each subtask can open its own nested
StructuredTaskScope, creating a tree of scopes. The tree structure mirrors the call hierarchy, making thread dumps readable and reasoning about concurrency much easier.